@memberjunction/server 5.37.0 → 5.38.0

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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Tests for ResolverBase.getRowLevelSecurityWhereClause.
3
+ *
4
+ * Validates that the resolver's RLS path delegates correctly to the centralized
5
+ * exemption check in EntityInfo.GetUserRowLevelSecurityWhereClause. This was
6
+ * the code path that caused the bug: single-record GraphQL resolvers applied
7
+ * RLS filters even when the user held a role that exempted them.
8
+ */
9
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
10
+
11
+ // ─── Hoisted mocks ────────────────────────────────────────────────────────
12
+ const { mockUserCacheUsers } = vi.hoisted(() => ({
13
+ mockUserCacheUsers: [] as Array<{ Email: string; ID: string }>,
14
+ }));
15
+
16
+ // Stub external deps before imports
17
+ vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
18
+ SQLServerDataProvider: class {},
19
+ UserCache: {
20
+ get Users() { return mockUserCacheUsers; },
21
+ },
22
+ }));
23
+
24
+ vi.mock('cloudevents', () => ({
25
+ CloudEvent: class {},
26
+ httpTransport: () => () => undefined,
27
+ emitterFor: () => () => undefined,
28
+ }));
29
+
30
+ vi.mock('type-graphql', () => ({
31
+ Resolver: () => () => undefined,
32
+ Mutation: () => () => undefined,
33
+ Query: () => () => undefined,
34
+ Subscription: () => () => undefined,
35
+ Ctx: () => () => undefined,
36
+ Arg: () => () => undefined,
37
+ PubSub: () => () => undefined,
38
+ Root: () => () => undefined,
39
+ ObjectType: () => () => undefined,
40
+ InputType: () => () => undefined,
41
+ Field: () => () => undefined,
42
+ FieldResolver: () => () => undefined,
43
+ Int: () => undefined,
44
+ Float: () => undefined,
45
+ registerEnumType: () => undefined,
46
+ }));
47
+
48
+ vi.mock('graphql', () => ({
49
+ GraphQLError: class extends Error {
50
+ constructor(msg: string) { super(msg); }
51
+ },
52
+ }));
53
+
54
+ vi.mock('mssql', () => ({}));
55
+
56
+ vi.mock('@memberjunction/api-keys', () => ({
57
+ GetAPIKeyEngine: vi.fn(),
58
+ }));
59
+
60
+ vi.mock('@memberjunction/encryption', () => ({
61
+ EncryptionEngine: { Instance: {} },
62
+ }));
63
+
64
+ vi.mock('@memberjunction/graphql-dataprovider', () => ({
65
+ FieldMapper: class { static Instance = { MapFieldsFromCodeNamesToDBNames: vi.fn() }; },
66
+ }));
67
+
68
+ vi.mock('../generic/PubSubManager.js', () => ({
69
+ PubSubManager: class { static Instance = { publish: vi.fn() }; },
70
+ }));
71
+
72
+ vi.mock('../generic/PushStatusResolver.js', () => ({
73
+ PUSH_STATUS_UPDATES_TOPIC: 'test-push-topic',
74
+ PushStatusNotification: class {},
75
+ PushStatusResolver: class {},
76
+ }));
77
+
78
+ vi.mock('../generic/CacheInvalidationResolver.js', () => ({
79
+ CACHE_INVALIDATION_TOPIC: 'test-cache-topic',
80
+ }));
81
+
82
+ vi.mock('../generic/RunViewResolver.js', () => ({
83
+ RunViewByIDInput: class {},
84
+ RunViewByNameInput: class {},
85
+ RunDynamicViewInput: class {},
86
+ }));
87
+
88
+ vi.mock('../generic/DeleteOptionsInput.js', () => ({
89
+ DeleteOptionsInput: class {},
90
+ }));
91
+
92
+ vi.mock('../types.js', () => ({
93
+ RunViewGenericParams: class {},
94
+ }));
95
+
96
+ vi.mock('@memberjunction/core', async () => {
97
+ const actual = await vi.importActual<typeof import('@memberjunction/core')>('@memberjunction/core');
98
+ return {
99
+ ...actual,
100
+ LogError: vi.fn(),
101
+ LogStatus: vi.fn(),
102
+ };
103
+ });
104
+
105
+ vi.mock('@memberjunction/core-entities', () => ({}));
106
+
107
+ // ─── Import after mocks ──────────────────────────────────────────────────
108
+ import { ResolverBase } from '../generic/ResolverBase';
109
+ import type { DatabaseProviderBase, EntityInfo, UserInfo } from '@memberjunction/core';
110
+
111
+ /**
112
+ * Subclass that exposes the protected getRowLevelSecurityWhereClause for testing.
113
+ */
114
+ class TestResolver extends ResolverBase {
115
+ public TestGetRLSWhereClause(
116
+ provider: DatabaseProviderBase,
117
+ entityName: string,
118
+ userPayload: { email: string },
119
+ type: string,
120
+ returnPrefix: string,
121
+ ): string {
122
+ return (this as unknown as {
123
+ getRowLevelSecurityWhereClause: (
124
+ p: DatabaseProviderBase, e: string, u: { email: string }, t: string, r: string,
125
+ ) => string;
126
+ }).getRowLevelSecurityWhereClause(provider, entityName, userPayload, type, returnPrefix);
127
+ }
128
+ }
129
+
130
+ // ─── Helpers ──────────────────────────────────────────────────────────────
131
+
132
+ function makeEntity(name: string, opts: {
133
+ exempt: boolean;
134
+ rlsClause: string;
135
+ }): EntityInfo {
136
+ return {
137
+ Name: name,
138
+ UserExemptFromRowLevelSecurity: () => opts.exempt,
139
+ GetUserRowLevelSecurityWhereClause: () => opts.exempt ? '' : opts.rlsClause,
140
+ } as unknown as EntityInfo;
141
+ }
142
+
143
+ function makeProvider(entities: EntityInfo[]): DatabaseProviderBase {
144
+ return {
145
+ Entities: entities,
146
+ } as unknown as DatabaseProviderBase;
147
+ }
148
+
149
+ // ─── Tests ────────────────────────────────────────────────────────────────
150
+
151
+ describe('ResolverBase.getRowLevelSecurityWhereClause', () => {
152
+ let resolver: TestResolver;
153
+
154
+ beforeEach(() => {
155
+ resolver = new TestResolver();
156
+ mockUserCacheUsers.length = 0;
157
+ mockUserCacheUsers.push({
158
+ Email: 'test@example.com',
159
+ ID: 'user-1',
160
+ });
161
+ });
162
+
163
+ it('returns empty string when user is exempt from RLS', () => {
164
+ const entity = makeEntity('Test Entity', { exempt: true, rlsClause: "OwnerID = 'user-1'" });
165
+ const provider = makeProvider([entity]);
166
+
167
+ const result = resolver.TestGetRLSWhereClause(
168
+ provider, 'Test Entity', { email: 'test@example.com' }, 'Read', 'AND',
169
+ );
170
+
171
+ expect(result).toBe('');
172
+ });
173
+
174
+ it('returns RLS clause when user is NOT exempt', () => {
175
+ const entity = makeEntity('Test Entity', { exempt: false, rlsClause: "AND OwnerID = 'user-1'" });
176
+ const provider = makeProvider([entity]);
177
+
178
+ const result = resolver.TestGetRLSWhereClause(
179
+ provider, 'Test Entity', { email: 'test@example.com' }, 'Read', 'AND',
180
+ );
181
+
182
+ expect(result).toContain('OwnerID');
183
+ });
184
+
185
+ it('throws when entity not found', () => {
186
+ const provider = makeProvider([]);
187
+
188
+ expect(() => resolver.TestGetRLSWhereClause(
189
+ provider, 'Nonexistent Entity', { email: 'test@example.com' }, 'Read', 'AND',
190
+ )).toThrow('Entity Nonexistent Entity not found');
191
+ });
192
+
193
+ it('throws when user not found in UserCache', () => {
194
+ const entity = makeEntity('Test Entity', { exempt: true, rlsClause: '' });
195
+ const provider = makeProvider([entity]);
196
+ mockUserCacheUsers.length = 0; // Clear cache
197
+
198
+ expect(() => resolver.TestGetRLSWhereClause(
199
+ provider, 'Test Entity', { email: 'unknown@example.com' }, 'Read', 'AND',
200
+ )).toThrow('User unknown@example.com not found');
201
+ });
202
+
203
+ it('matches entity name case-insensitively', () => {
204
+ const entity = makeEntity('MJ: AI Prompt Runs', { exempt: true, rlsClause: '' });
205
+ const provider = makeProvider([entity]);
206
+
207
+ const result = resolver.TestGetRLSWhereClause(
208
+ provider, 'mj: ai prompt runs', { email: 'test@example.com' }, 'Read', 'AND',
209
+ );
210
+
211
+ expect(result).toBe('');
212
+ });
213
+
214
+ it('matches user email case-insensitively', () => {
215
+ const entity = makeEntity('Test Entity', { exempt: true, rlsClause: '' });
216
+ const provider = makeProvider([entity]);
217
+
218
+ const result = resolver.TestGetRLSWhereClause(
219
+ provider, 'Test Entity', { email: 'TEST@EXAMPLE.COM' }, 'Read', 'AND',
220
+ );
221
+
222
+ expect(result).toBe('');
223
+ });
224
+ });
@@ -25,7 +25,7 @@ import {
25
25
  } from '@memberjunction/skip-types';
26
26
  import { DataContext } from '@memberjunction/data-context';
27
27
  import { IMetadataProvider, UserInfo, LogStatus, LogError, Metadata, RunQuery, RunView, EntityInfo, EntityFieldInfo, EntityFieldValueInfo, DatabaseProviderBase } from '@memberjunction/core';
28
- import { MJConversationDetailEntity } from '@memberjunction/core-entities';
28
+ import { MJConversationDetailEntity, QueryEngine } from '@memberjunction/core-entities';
29
29
  import { request as httpRequest } from 'http';
30
30
  import { request as httpsRequest } from 'https';
31
31
  import { gzip as gzipCompress, createGunzip } from 'zlib';
@@ -483,15 +483,15 @@ export class SkipSDK {
483
483
  * Build saved queries for Skip
484
484
  */
485
485
  private buildQueries(status: "Pending" | "In-Review" | "Approved" | "Rejected" | "Obsolete" = 'Approved'): SkipQueryInfo[] {
486
- const md = this.Provider;
487
- const approvedQueries = md.Queries.filter((q) => q.Status === status);
486
+ const qe = QueryEngine.Instance;
487
+ const approvedQueries = qe.Queries.filter((q) => q.Status === status);
488
488
 
489
489
  return approvedQueries.map((q) => ({
490
490
  ID: q.ID,
491
491
  Name: q.Name,
492
492
  Description: q.Description,
493
493
  Category: q.Category,
494
- CategoryPath: this.buildQueryCategoryPath(md, q.CategoryID),
494
+ CategoryPath: this.buildQueryCategoryPath(qe, q.CategoryID),
495
495
  CategoryID: q.CategoryID,
496
496
  SQL: q.SQL,
497
497
  Status: q.Status,
@@ -501,7 +501,7 @@ export class SkipSDK {
501
501
  EmbeddingModelID: q.EmbeddingModelID,
502
502
  EmbeddingModelName: q.EmbeddingModel,
503
503
  TechnicalDescription: q.TechnicalDescription,
504
- Fields: q.Fields.map((f) => ({
504
+ Fields: qe.GetQueryFields(q.ID).map((f) => ({
505
505
  ID: f.ID,
506
506
  QueryID: f.QueryID,
507
507
  Name: f.Name,
@@ -517,7 +517,7 @@ export class SkipSDK {
517
517
  IsSummary: f.IsSummary,
518
518
  SummaryDescription: f.SummaryDescription
519
519
  })),
520
- Parameters: q.Parameters.map((p) => ({
520
+ Parameters: qe.GetQueryParameters(q.ID).map((p) => ({
521
521
  ID: p.ID,
522
522
  QueryID: p.QueryID,
523
523
  Name: p.Name,
@@ -528,12 +528,15 @@ export class SkipSDK {
528
528
  SampleValue: p.SampleValue,
529
529
  ValidationFilters: p.ValidationFilters
530
530
  })),
531
- Entities: q.Entities.map((e) => ({
531
+ Entities: qe.QueryEntities.filter(e => UUIDsEqual(e.QueryID, q.ID)).map((e) => ({
532
532
  ID: e.ID,
533
533
  QueryID: e.QueryID,
534
534
  EntityID: e.EntityID,
535
535
  Entity: e.Entity
536
536
  })),
537
+ SQLDialectID: q.SQLDialectID,
538
+ UsesTemplate: q.UsesTemplate,
539
+ IsApproved: q.IsApproved,
537
540
  CacheEnabled: q.CacheEnabled,
538
541
  CacheMaxSize: q.CacheMaxSize,
539
542
  CacheTTLMinutes: q.CacheTTLMinutes,
@@ -544,11 +547,11 @@ export class SkipSDK {
544
547
  /**
545
548
  * Recursively build category path for a query
546
549
  */
547
- private buildQueryCategoryPath(md: IMetadataProvider, categoryID: string): string {
548
- const cat = md.QueryCategories.find((c) => UUIDsEqual(c.ID, categoryID));
550
+ private buildQueryCategoryPath(qe: QueryEngine, categoryID: string): string {
551
+ const cat = qe.Categories.find((c) => UUIDsEqual(c.ID, categoryID));
549
552
  if (!cat) return '';
550
553
  if (!cat.ParentID) return cat.Name;
551
- const parentPath = this.buildQueryCategoryPath(md, cat.ParentID);
554
+ const parentPath = this.buildQueryCategoryPath(qe, cat.ParentID);
552
555
  return parentPath ? `${parentPath}/${cat.Name}` : cat.Name;
553
556
  }
554
557
 
@@ -560,11 +563,11 @@ export class SkipSDK {
560
563
  * across all queries, not just approved ones.
561
564
  */
562
565
  private buildQueryCatalog(): SkipQueryCatalogEntry[] {
563
- const md = this.Provider;
566
+ const qe = QueryEngine.Instance;
564
567
 
565
- return md.Queries.map((q) => ({
568
+ return qe.Queries.map((q) => ({
566
569
  Name: q.Name,
567
- CategoryPath: this.buildQueryCategoryPath(md, q.CategoryID)
570
+ CategoryPath: this.buildQueryCategoryPath(qe, q.CategoryID)
568
571
  }));
569
572
  }
570
573