@memberjunction/server 5.50.0 → 5.51.1
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/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +6 -1
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/auth/index.d.ts +8 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +53 -13
- package/dist/auth/index.js.map +1 -1
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +19 -2
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.d.ts +22 -0
- package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.js +36 -0
- package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +21 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +75 -16
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/ReportResolver.d.ts.map +1 -1
- package/dist/resolvers/ReportResolver.js +2 -1
- package/dist/resolvers/ReportResolver.js.map +1 -1
- package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +129 -43
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/package.json +89 -89
- package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +420 -0
- package/src/__tests__/SessionManager.test.ts +62 -0
- package/src/__tests__/newUsers.test.ts +729 -0
- package/src/__tests__/widgetGuestElevation.test.ts +70 -2
- package/src/agentSessions/SessionManager.ts +6 -1
- package/src/auth/index.ts +56 -15
- package/src/config.ts +12 -0
- package/src/context.ts +19 -2
- package/src/index.ts +3 -1
- package/src/realtimeWidget/widgetGuestElevation.ts +41 -0
- package/src/resolvers/RealtimeClientSessionResolver.ts +84 -16
- package/src/resolvers/ReportResolver.ts +2 -1
- package/src/resolvers/__tests__/ReportResolver.test.ts +232 -0
- package/src/rest/OAuthCallbackHandler.ts +149 -41
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the new-user provisioning flow:
|
|
3
|
+
*
|
|
4
|
+
* - NewUserBase.createNewUser (auth/newUsers.ts): context-user resolution,
|
|
5
|
+
* field mapping, transactional role / application / application-entity
|
|
6
|
+
* provisioning, and every rollback path.
|
|
7
|
+
* - verifyUserRecord (auth/index.ts): the authorization gate in front of it —
|
|
8
|
+
* autoCreateNewUsers on/off, authorized-domain restrictions against the
|
|
9
|
+
* verified identity's email domain (including wildcards, suffix/prefix
|
|
10
|
+
* confusion, and a forged Origin), cache-refresh retry, and the UserRoles
|
|
11
|
+
* the created user is stamped with.
|
|
12
|
+
*
|
|
13
|
+
* Both production modules run unmodified; mocking happens at the package
|
|
14
|
+
* boundaries (@memberjunction/core, generic-database-provider, core-entities)
|
|
15
|
+
* plus the config module, matching unifiedAuth.test.ts.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
18
|
+
import type { UserInfo } from '@memberjunction/core';
|
|
19
|
+
|
|
20
|
+
// ─── Hoisted state ──────────────────────────────────────────────────────────
|
|
21
|
+
const {
|
|
22
|
+
mockConfig,
|
|
23
|
+
mockCacheUsers,
|
|
24
|
+
mockUserByName,
|
|
25
|
+
mockRefresh,
|
|
26
|
+
mockGetEntityObject,
|
|
27
|
+
mockRunViewFn,
|
|
28
|
+
mockLogError,
|
|
29
|
+
mockLogStatus,
|
|
30
|
+
mockBeginTransaction,
|
|
31
|
+
mockCommitTransaction,
|
|
32
|
+
mockRollbackTransaction,
|
|
33
|
+
mockGetDefaultApplicationsForNewUser,
|
|
34
|
+
mockRolesArray,
|
|
35
|
+
mockApplicationsArray,
|
|
36
|
+
} = vi.hoisted(() => {
|
|
37
|
+
interface HoistedCacheUser {
|
|
38
|
+
ID: string;
|
|
39
|
+
Name: string;
|
|
40
|
+
Email: string;
|
|
41
|
+
Type: string;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
mockConfig: {
|
|
45
|
+
userHandling: {
|
|
46
|
+
autoCreateNewUsers: true,
|
|
47
|
+
newUserLimitedToAuthorizedDomains: false,
|
|
48
|
+
newUserAuthorizedDomains: [] as string[],
|
|
49
|
+
newUserRoles: ['UI'] as string[],
|
|
50
|
+
updateCacheWhenNotFound: false,
|
|
51
|
+
updateCacheWhenNotFoundDelay: 0,
|
|
52
|
+
contextUserForNewUserCreation: 'system@test.com',
|
|
53
|
+
CreateUserApplicationRecords: false,
|
|
54
|
+
UserApplications: [] as string[],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
mockCacheUsers: [] as HoistedCacheUser[],
|
|
58
|
+
mockUserByName: vi.fn(),
|
|
59
|
+
mockRefresh: vi.fn(),
|
|
60
|
+
mockGetEntityObject: vi.fn(),
|
|
61
|
+
mockRunViewFn: vi.fn(),
|
|
62
|
+
mockLogError: vi.fn(),
|
|
63
|
+
mockLogStatus: vi.fn(),
|
|
64
|
+
mockBeginTransaction: vi.fn(),
|
|
65
|
+
mockCommitTransaction: vi.fn(),
|
|
66
|
+
mockRollbackTransaction: vi.fn(),
|
|
67
|
+
mockGetDefaultApplicationsForNewUser: vi.fn(),
|
|
68
|
+
mockRolesArray: [] as Array<{ ID: string; Name: string }>,
|
|
69
|
+
mockApplicationsArray: [] as Array<{ ID: string; Name: string }>,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ─── Module mocks ───────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
vi.mock('../config.js', () => ({ configInfo: mockConfig }));
|
|
76
|
+
|
|
77
|
+
// On the 5.x line UserCache is exported by @memberjunction/sqlserver-dataprovider, not
|
|
78
|
+
// generic-database-provider — mocking it here is what keeps the real SQLServerDataProvider
|
|
79
|
+
// (and its mssql-dependent config module) out of the module graph.
|
|
80
|
+
vi.mock('@memberjunction/sqlserver-dataprovider', () => {
|
|
81
|
+
const instance = {
|
|
82
|
+
UserByName: mockUserByName,
|
|
83
|
+
get Users() {
|
|
84
|
+
return mockCacheUsers;
|
|
85
|
+
},
|
|
86
|
+
Refresh: mockRefresh,
|
|
87
|
+
GetSystemUser: vi.fn(),
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
UserCache: class MockUserCache {
|
|
91
|
+
static get Instance() {
|
|
92
|
+
return instance;
|
|
93
|
+
}
|
|
94
|
+
static get Users() {
|
|
95
|
+
return mockCacheUsers;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
102
|
+
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
103
|
+
const provider = {
|
|
104
|
+
BeginTransaction: mockBeginTransaction,
|
|
105
|
+
CommitTransaction: mockCommitTransaction,
|
|
106
|
+
RollbackTransaction: mockRollbackTransaction,
|
|
107
|
+
Dialect: { BooleanLiteral: (value: boolean) => (value ? '1' : '0') },
|
|
108
|
+
};
|
|
109
|
+
class MockMetadata {
|
|
110
|
+
public get Roles() {
|
|
111
|
+
return mockRolesArray;
|
|
112
|
+
}
|
|
113
|
+
public get Applications() {
|
|
114
|
+
return mockApplicationsArray;
|
|
115
|
+
}
|
|
116
|
+
public GetEntityObject(entityName: string, contextUser?: UserInfo): Promise<unknown> {
|
|
117
|
+
return mockGetEntityObject(entityName, contextUser);
|
|
118
|
+
}
|
|
119
|
+
static Provider = provider;
|
|
120
|
+
}
|
|
121
|
+
class MockUserInfo {
|
|
122
|
+
constructor(_provider: unknown, initData: Record<string, unknown>) {
|
|
123
|
+
Object.assign(this, initData);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
class MockRunView {
|
|
127
|
+
public RunView(params: unknown, contextUser?: UserInfo): Promise<unknown> {
|
|
128
|
+
return mockRunViewFn(params, contextUser);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
...actual,
|
|
133
|
+
Metadata: MockMetadata,
|
|
134
|
+
UserInfo: MockUserInfo,
|
|
135
|
+
RunView: MockRunView,
|
|
136
|
+
LogError: mockLogError,
|
|
137
|
+
LogStatus: mockLogStatus,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
vi.mock('@memberjunction/core-entities', async (importOriginal) => {
|
|
142
|
+
const actual = await importOriginal<typeof import('@memberjunction/core-entities')>();
|
|
143
|
+
return {
|
|
144
|
+
...actual,
|
|
145
|
+
UserInfoEngine: {
|
|
146
|
+
GetDefaultApplicationsForNewUser: mockGetDefaultApplicationsForNewUser,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
vi.mock('@memberjunction/global', async (importOriginal) => {
|
|
152
|
+
const actual = await importOriginal<typeof import('@memberjunction/global')>();
|
|
153
|
+
return {
|
|
154
|
+
...actual,
|
|
155
|
+
MJGlobal: {
|
|
156
|
+
get Instance() {
|
|
157
|
+
return {
|
|
158
|
+
ClassFactory: {
|
|
159
|
+
CreateInstance: <T>(cls: new () => T): T => new cls(),
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
vi.mock('../auth/initializeProviders.js', () => ({ initializeAuthProviders: vi.fn() }));
|
|
168
|
+
|
|
169
|
+
vi.mock('@memberjunction/auth-providers', () => ({
|
|
170
|
+
AuthProviderFactory: {
|
|
171
|
+
get Instance() {
|
|
172
|
+
return {
|
|
173
|
+
getAllByIssuer: () => [],
|
|
174
|
+
getByIssuer: () => undefined,
|
|
175
|
+
getAllProviders: () => [],
|
|
176
|
+
hasProviders: () => false,
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
vi.mock('mssql', () => ({ default: {} }));
|
|
183
|
+
|
|
184
|
+
vi.mock('type-graphql', () => ({
|
|
185
|
+
AuthorizationError: class AuthorizationError extends Error {},
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
vi.mock('@memberjunction/api-keys', () => ({
|
|
189
|
+
GetAPIKeyEngine: vi.fn(),
|
|
190
|
+
}));
|
|
191
|
+
|
|
192
|
+
// ─── Import after mocks ─────────────────────────────────────────────────────
|
|
193
|
+
import { NewUserBase } from '../auth/newUsers.js';
|
|
194
|
+
import { verifyUserRecord } from '../auth/index.js';
|
|
195
|
+
|
|
196
|
+
// ─── Mock-entity plumbing ───────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
interface MockEntity {
|
|
199
|
+
[key: string]: unknown;
|
|
200
|
+
TestEntityName: string;
|
|
201
|
+
NewRecord: ReturnType<typeof vi.fn>;
|
|
202
|
+
Save: ReturnType<typeof vi.fn>;
|
|
203
|
+
GetAll: () => Record<string, unknown>;
|
|
204
|
+
LatestResult: { CompleteMessage: string };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Entities handed out by the mocked Metadata.GetEntityObject, in creation order. */
|
|
208
|
+
let createdEntities: MockEntity[];
|
|
209
|
+
/** Per-entity-name Save() outcome; defaults to success. */
|
|
210
|
+
let saveBehavior: Record<string, () => boolean>;
|
|
211
|
+
/** Every GetEntityObject call: entity name + the contextUser it was scoped to. */
|
|
212
|
+
let getEntityObjectCalls: Array<{ entityName: string; contextUser: UserInfo | undefined }>;
|
|
213
|
+
|
|
214
|
+
let entitySeq = 0;
|
|
215
|
+
|
|
216
|
+
function makeMockEntity(entityName: string): MockEntity {
|
|
217
|
+
const entity: MockEntity = {
|
|
218
|
+
TestEntityName: entityName,
|
|
219
|
+
ID: `${entityName}#${++entitySeq}`,
|
|
220
|
+
NewRecord: vi.fn(),
|
|
221
|
+
LatestResult: { CompleteMessage: `mock save failure for ${entityName}` },
|
|
222
|
+
Save: vi.fn(async () => (saveBehavior[entityName] ?? (() => true))()),
|
|
223
|
+
GetAll: (): Record<string, unknown> => {
|
|
224
|
+
const out: Record<string, unknown> = {};
|
|
225
|
+
for (const [key, value] of Object.entries(entity)) {
|
|
226
|
+
if (typeof value !== 'function' && key !== 'LatestResult' && key !== 'TestEntityName') {
|
|
227
|
+
out[key] = value;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
return entity;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function entitiesOf(name: string): MockEntity[] {
|
|
237
|
+
return createdEntities.filter((e) => e.TestEntityName === name);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const CONTEXT_USER = { ID: 'sys-1', Name: 'system@test.com', Email: 'system@test.com', Type: 'Owner' } as unknown as UserInfo;
|
|
241
|
+
|
|
242
|
+
function resetConfig(): void {
|
|
243
|
+
mockConfig.userHandling = {
|
|
244
|
+
autoCreateNewUsers: true,
|
|
245
|
+
newUserLimitedToAuthorizedDomains: false,
|
|
246
|
+
newUserAuthorizedDomains: [],
|
|
247
|
+
newUserRoles: ['UI'],
|
|
248
|
+
updateCacheWhenNotFound: false,
|
|
249
|
+
updateCacheWhenNotFoundDelay: 0,
|
|
250
|
+
contextUserForNewUserCreation: 'system@test.com',
|
|
251
|
+
CreateUserApplicationRecords: false,
|
|
252
|
+
UserApplications: [],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
beforeEach(() => {
|
|
257
|
+
vi.clearAllMocks();
|
|
258
|
+
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
259
|
+
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
260
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
261
|
+
|
|
262
|
+
resetConfig();
|
|
263
|
+
createdEntities = [];
|
|
264
|
+
getEntityObjectCalls = [];
|
|
265
|
+
saveBehavior = {};
|
|
266
|
+
entitySeq = 0;
|
|
267
|
+
mockCacheUsers.length = 0;
|
|
268
|
+
|
|
269
|
+
mockRolesArray.length = 0;
|
|
270
|
+
mockRolesArray.push({ ID: 'role-ui', Name: 'UI' }, { ID: 'role-dev', Name: 'Developer' });
|
|
271
|
+
mockApplicationsArray.length = 0;
|
|
272
|
+
mockApplicationsArray.push({ ID: 'app-crm', Name: ' CRM ' }, { ID: 'app-admin', Name: 'Admin' });
|
|
273
|
+
|
|
274
|
+
mockUserByName.mockImplementation((name: string) => (name === 'system@test.com' ? CONTEXT_USER : undefined));
|
|
275
|
+
mockGetEntityObject.mockImplementation(async (entityName: string, contextUser?: UserInfo) => {
|
|
276
|
+
const entity = makeMockEntity(entityName);
|
|
277
|
+
createdEntities.push(entity);
|
|
278
|
+
getEntityObjectCalls.push({ entityName, contextUser });
|
|
279
|
+
return entity;
|
|
280
|
+
});
|
|
281
|
+
mockRunViewFn.mockResolvedValue({ Success: true, Results: [] });
|
|
282
|
+
mockGetDefaultApplicationsForNewUser.mockReturnValue([]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ─── NewUserBase.createNewUser ──────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
describe('NewUserBase.createNewUser', () => {
|
|
288
|
+
const create = (first = 'Ada', last = 'Lovelace', email = 'ada@example.com') =>
|
|
289
|
+
new NewUserBase().createNewUser(first, last, email);
|
|
290
|
+
|
|
291
|
+
describe('context-user resolution', () => {
|
|
292
|
+
it('uses the configured contextUserForNewUserCreation for every entity it creates', async () => {
|
|
293
|
+
const user = await create();
|
|
294
|
+
|
|
295
|
+
expect(user).not.toBeNull();
|
|
296
|
+
expect(mockUserByName).toHaveBeenCalledWith('system@test.com');
|
|
297
|
+
expect(getEntityObjectCalls[0]).toEqual({ entityName: 'MJ: Users', contextUser: CONTEXT_USER });
|
|
298
|
+
// Role records are scoped to the same creation context user
|
|
299
|
+
for (const call of getEntityObjectCalls) {
|
|
300
|
+
expect(call.contextUser).toBe(CONTEXT_USER);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('falls back to an Owner-typed cache user (case-insensitive, trimmed) when the configured user is missing', async () => {
|
|
305
|
+
mockUserByName.mockReturnValue(undefined);
|
|
306
|
+
const owner = { ID: 'owner-9', Name: 'Fallback Owner', Email: 'owner@x.com', Type: ' OWNER ' };
|
|
307
|
+
mockCacheUsers.push({ ID: 'u-1', Name: 'Plain', Email: 'p@x.com', Type: 'User' }, owner);
|
|
308
|
+
|
|
309
|
+
const user = await create();
|
|
310
|
+
|
|
311
|
+
expect(user).not.toBeNull();
|
|
312
|
+
expect(mockLogError).toHaveBeenCalled(); // the miss is logged
|
|
313
|
+
expect(getEntityObjectCalls[0].contextUser).toBe(owner as unknown as UserInfo);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('returns null without opening a transaction when no context user can be resolved at all', async () => {
|
|
317
|
+
mockUserByName.mockReturnValue(undefined);
|
|
318
|
+
mockCacheUsers.push({ ID: 'u-1', Name: 'Plain', Email: 'p@x.com', Type: 'User' });
|
|
319
|
+
|
|
320
|
+
const user = await create();
|
|
321
|
+
|
|
322
|
+
expect(user).toBeNull();
|
|
323
|
+
expect(mockBeginTransaction).not.toHaveBeenCalled();
|
|
324
|
+
expect(mockGetEntityObject).not.toHaveBeenCalled();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('returns undefined (outer catch) when context resolution throws', async () => {
|
|
328
|
+
mockUserByName.mockImplementation(() => {
|
|
329
|
+
throw new Error('cache exploded');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const user = await create();
|
|
333
|
+
|
|
334
|
+
expect(user).toBeUndefined();
|
|
335
|
+
expect(mockLogError).toHaveBeenCalled();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe('user record construction', () => {
|
|
340
|
+
it('maps the identity fields onto a NewRecord-ed MJ: Users entity', async () => {
|
|
341
|
+
const user = await create('Grace', 'Hopper', 'grace@navy.mil');
|
|
342
|
+
|
|
343
|
+
const userEntity = entitiesOf('MJ: Users')[0];
|
|
344
|
+
expect(user as unknown).toBe(userEntity); // the saved entity itself is returned
|
|
345
|
+
expect(userEntity.NewRecord).toHaveBeenCalledTimes(1);
|
|
346
|
+
expect(userEntity.Name).toBe('grace@navy.mil'); // Name IS the email
|
|
347
|
+
expect(userEntity.Email).toBe('grace@navy.mil');
|
|
348
|
+
expect(userEntity.FirstName).toBe('Grace');
|
|
349
|
+
expect(userEntity.LastName).toBe('Hopper');
|
|
350
|
+
expect(userEntity.IsActive).toBe(true);
|
|
351
|
+
expect(userEntity.Type).toBe('User');
|
|
352
|
+
expect(userEntity.LinkedRecordType).toBe('None'); // default
|
|
353
|
+
expect(userEntity.LinkedEntityID).toBeUndefined();
|
|
354
|
+
expect(userEntity.LinkedEntityRecordID).toBeUndefined();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('sets linked-record fields only when provided', async () => {
|
|
358
|
+
await new NewUserBase().createNewUser('A', 'B', 'ab@x.com', 'Employee', 'ent-77', 'rec-88');
|
|
359
|
+
|
|
360
|
+
const userEntity = entitiesOf('MJ: Users')[0];
|
|
361
|
+
expect(userEntity.LinkedRecordType).toBe('Employee');
|
|
362
|
+
expect(userEntity.LinkedEntityID).toBe('ent-77');
|
|
363
|
+
expect(userEntity.LinkedEntityRecordID).toBe('rec-88');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('rolls back and returns null when the user Save() fails — never a half-provisioned user', async () => {
|
|
367
|
+
saveBehavior['MJ: Users'] = () => false;
|
|
368
|
+
|
|
369
|
+
const user = await create();
|
|
370
|
+
|
|
371
|
+
expect(user).toBeNull();
|
|
372
|
+
expect(mockBeginTransaction).toHaveBeenCalledTimes(1);
|
|
373
|
+
expect(mockRollbackTransaction).toHaveBeenCalledTimes(1);
|
|
374
|
+
expect(mockCommitTransaction).not.toHaveBeenCalled();
|
|
375
|
+
expect(entitiesOf('MJ: User Roles')).toHaveLength(0);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe('role assignment', () => {
|
|
380
|
+
it('creates one saved MJ: User Roles record per configured role, inside the transaction', async () => {
|
|
381
|
+
mockConfig.userHandling.newUserRoles = ['UI', 'Developer'];
|
|
382
|
+
|
|
383
|
+
const user = await create();
|
|
384
|
+
|
|
385
|
+
expect(user).not.toBeNull();
|
|
386
|
+
const roleEntities = entitiesOf('MJ: User Roles');
|
|
387
|
+
expect(roleEntities).toHaveLength(2);
|
|
388
|
+
const userEntity = entitiesOf('MJ: Users')[0];
|
|
389
|
+
expect(roleEntities[0].UserID).toBe(userEntity.ID);
|
|
390
|
+
expect(roleEntities[0].RoleID).toBe('role-ui');
|
|
391
|
+
expect(roleEntities[1].RoleID).toBe('role-dev');
|
|
392
|
+
for (const roleEntity of roleEntities) {
|
|
393
|
+
expect(roleEntity.Save).toHaveBeenCalledTimes(1);
|
|
394
|
+
}
|
|
395
|
+
expect(mockCommitTransaction).toHaveBeenCalledTimes(1);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('skips unknown roles with a logged error but still commits (KNOWN GAP: role-name match is case-sensitive)', async () => {
|
|
399
|
+
// md.Roles.find(r => r.Name === role) — 'ui' does NOT match the metadata
|
|
400
|
+
// role 'UI'. A casing typo in mj.config silently yields a user with fewer
|
|
401
|
+
// roles than intended. Pinned as documentation of the sharp edge.
|
|
402
|
+
mockConfig.userHandling.newUserRoles = ['ui', 'Developer'];
|
|
403
|
+
|
|
404
|
+
const user = await create();
|
|
405
|
+
|
|
406
|
+
expect(user).not.toBeNull();
|
|
407
|
+
const roleEntities = entitiesOf('MJ: User Roles');
|
|
408
|
+
expect(roleEntities).toHaveLength(1);
|
|
409
|
+
expect(roleEntities[0].RoleID).toBe('role-dev');
|
|
410
|
+
expect(mockLogError).toHaveBeenCalledWith(expect.stringContaining('Role ui not found'));
|
|
411
|
+
expect(mockCommitTransaction).toHaveBeenCalledTimes(1);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('rolls back everything when a role Save() fails', async () => {
|
|
415
|
+
mockConfig.userHandling.newUserRoles = ['UI'];
|
|
416
|
+
saveBehavior['MJ: User Roles'] = () => false;
|
|
417
|
+
|
|
418
|
+
const user = await create();
|
|
419
|
+
|
|
420
|
+
expect(user).toBeNull();
|
|
421
|
+
expect(mockRollbackTransaction).toHaveBeenCalledTimes(1);
|
|
422
|
+
expect(mockCommitTransaction).not.toHaveBeenCalled();
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('user application provisioning', () => {
|
|
427
|
+
beforeEach(() => {
|
|
428
|
+
mockConfig.userHandling.CreateUserApplicationRecords = true;
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('creates User Applications for explicitly configured app names (trimmed, case-insensitive)', async () => {
|
|
432
|
+
mockConfig.userHandling.UserApplications = ['crm', 'ADMIN '];
|
|
433
|
+
|
|
434
|
+
const user = await create();
|
|
435
|
+
|
|
436
|
+
expect(user).not.toBeNull();
|
|
437
|
+
const appEntities = entitiesOf('MJ: User Applications');
|
|
438
|
+
expect(appEntities).toHaveLength(2);
|
|
439
|
+
expect(appEntities[0].ApplicationID).toBe('app-crm');
|
|
440
|
+
expect(appEntities[0].Sequence).toBe(0);
|
|
441
|
+
expect(appEntities[0].IsActive).toBe(true);
|
|
442
|
+
expect(appEntities[1].ApplicationID).toBe('app-admin');
|
|
443
|
+
expect(appEntities[1].Sequence).toBe(1);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('logs and skips configured app names that do not exist in metadata', async () => {
|
|
447
|
+
mockConfig.userHandling.UserApplications = ['NoSuchApp', 'Admin'];
|
|
448
|
+
|
|
449
|
+
const user = await create();
|
|
450
|
+
|
|
451
|
+
expect(user).not.toBeNull();
|
|
452
|
+
expect(entitiesOf('MJ: User Applications')).toHaveLength(1);
|
|
453
|
+
expect(mockLogError).toHaveBeenCalledWith(expect.stringContaining('Application NoSuchApp not found'));
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('falls back to UserInfoEngine.GetDefaultApplicationsForNewUser when no apps are configured', async () => {
|
|
457
|
+
mockConfig.userHandling.UserApplications = [];
|
|
458
|
+
mockGetDefaultApplicationsForNewUser.mockReturnValue([{ ID: 'app-admin', Name: 'Admin' }]);
|
|
459
|
+
|
|
460
|
+
const user = await create();
|
|
461
|
+
|
|
462
|
+
expect(user).not.toBeNull();
|
|
463
|
+
expect(mockGetDefaultApplicationsForNewUser).toHaveBeenCalledTimes(1);
|
|
464
|
+
const appEntities = entitiesOf('MJ: User Applications');
|
|
465
|
+
expect(appEntities).toHaveLength(1);
|
|
466
|
+
expect(appEntities[0].ApplicationID).toBe('app-admin');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('creates User Application Entities from the DefaultForNewUser view rows, using the dialect boolean literal', async () => {
|
|
470
|
+
mockConfig.userHandling.UserApplications = ['Admin'];
|
|
471
|
+
mockRunViewFn.mockResolvedValue({
|
|
472
|
+
Success: true,
|
|
473
|
+
Results: [
|
|
474
|
+
{ EntityID: 'ent-cust', Entity: 'Customers' },
|
|
475
|
+
{ EntityID: 'ent-ord', Entity: 'Orders' },
|
|
476
|
+
],
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const user = await create();
|
|
480
|
+
|
|
481
|
+
expect(user).not.toBeNull();
|
|
482
|
+
expect(mockRunViewFn).toHaveBeenCalledWith(
|
|
483
|
+
{
|
|
484
|
+
EntityName: 'MJ: Application Entities',
|
|
485
|
+
ExtraFilter: `ApplicationID = 'app-admin' AND DefaultForNewUser = 1`,
|
|
486
|
+
},
|
|
487
|
+
CONTEXT_USER,
|
|
488
|
+
);
|
|
489
|
+
const appEntity = entitiesOf('MJ: User Applications')[0];
|
|
490
|
+
const uae = entitiesOf('MJ: User Application Entities');
|
|
491
|
+
expect(uae).toHaveLength(2);
|
|
492
|
+
expect(uae[0].UserApplicationID).toBe(appEntity.ID);
|
|
493
|
+
expect(uae[0].EntityID).toBe('ent-cust');
|
|
494
|
+
expect(uae[0].Sequence).toBe(0);
|
|
495
|
+
expect(uae[1].EntityID).toBe('ent-ord');
|
|
496
|
+
expect(uae[1].Sequence).toBe(1);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('continues (and still commits) when the Application Entities view fails to load', async () => {
|
|
500
|
+
mockConfig.userHandling.UserApplications = ['Admin'];
|
|
501
|
+
mockRunViewFn.mockResolvedValue({ Success: false, Results: [], ErrorMessage: 'view blew up' });
|
|
502
|
+
|
|
503
|
+
const user = await create();
|
|
504
|
+
|
|
505
|
+
expect(user).not.toBeNull();
|
|
506
|
+
expect(entitiesOf('MJ: User Application Entities')).toHaveLength(0);
|
|
507
|
+
expect(mockCommitTransaction).toHaveBeenCalledTimes(1);
|
|
508
|
+
expect(mockRollbackTransaction).not.toHaveBeenCalled();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('rolls back when a User Application Entity Save() fails', async () => {
|
|
512
|
+
mockConfig.userHandling.UserApplications = ['Admin'];
|
|
513
|
+
mockRunViewFn.mockResolvedValue({ Success: true, Results: [{ EntityID: 'ent-1', Entity: 'X' }] });
|
|
514
|
+
saveBehavior['MJ: User Application Entities'] = () => false;
|
|
515
|
+
|
|
516
|
+
const user = await create();
|
|
517
|
+
|
|
518
|
+
expect(user).toBeNull();
|
|
519
|
+
expect(mockRollbackTransaction).toHaveBeenCalledTimes(1);
|
|
520
|
+
expect(mockCommitTransaction).not.toHaveBeenCalled();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('rolls back when a User Application Save() fails', async () => {
|
|
524
|
+
mockConfig.userHandling.UserApplications = ['Admin'];
|
|
525
|
+
saveBehavior['MJ: User Applications'] = () => false;
|
|
526
|
+
|
|
527
|
+
const user = await create();
|
|
528
|
+
|
|
529
|
+
expect(user).toBeNull();
|
|
530
|
+
expect(mockRollbackTransaction).toHaveBeenCalledTimes(1);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// ─── verifyUserRecord (the authorization gate) ──────────────────────────────
|
|
536
|
+
|
|
537
|
+
describe('verifyUserRecord', () => {
|
|
538
|
+
function addCacheUser(email: string): void {
|
|
539
|
+
mockCacheUsers.push({ ID: `u-${email}`, Name: email, Email: email, Type: 'User' });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
it('returns undefined when no email is supplied', async () => {
|
|
543
|
+
const user = await verifyUserRecord(undefined, 'A', 'B');
|
|
544
|
+
|
|
545
|
+
expect(user).toBeUndefined();
|
|
546
|
+
expect(mockGetEntityObject).not.toHaveBeenCalled();
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('returns the cached user, matching email case-insensitively and trimmed', async () => {
|
|
550
|
+
addCacheUser('someone@example.com');
|
|
551
|
+
|
|
552
|
+
const user = await verifyUserRecord(' SomeOne@Example.COM ', 'A', 'B');
|
|
553
|
+
|
|
554
|
+
expect(user).toBeDefined();
|
|
555
|
+
expect((user as unknown as { Email: string }).Email).toBe('someone@example.com');
|
|
556
|
+
expect(mockGetEntityObject).not.toHaveBeenCalled(); // no creation for existing users
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('skips (and logs) cache entries with a blank email instead of matching them', async () => {
|
|
560
|
+
mockCacheUsers.push({ ID: 'broken-1', Name: 'Broken', Email: ' ', Type: 'User' });
|
|
561
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
562
|
+
|
|
563
|
+
const user = await verifyUserRecord('missing@example.com', undefined, undefined);
|
|
564
|
+
|
|
565
|
+
expect(user).toBeUndefined();
|
|
566
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('broken-1'));
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
describe('autoCreateNewUsers gate', () => {
|
|
570
|
+
it('does NOT create a user when autoCreateNewUsers is false', async () => {
|
|
571
|
+
mockConfig.userHandling.autoCreateNewUsers = false;
|
|
572
|
+
|
|
573
|
+
const user = await verifyUserRecord('new@example.com', 'New', 'User');
|
|
574
|
+
|
|
575
|
+
expect(user).toBeUndefined();
|
|
576
|
+
expect(mockGetEntityObject).not.toHaveBeenCalled();
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('does NOT create a user when firstName or lastName is missing', async () => {
|
|
580
|
+
const missingFirst = await verifyUserRecord('new@example.com', undefined, 'User');
|
|
581
|
+
const missingLast = await verifyUserRecord('new@example.com', 'New', undefined);
|
|
582
|
+
|
|
583
|
+
expect(missingFirst).toBeUndefined();
|
|
584
|
+
expect(missingLast).toBeUndefined();
|
|
585
|
+
expect(mockGetEntityObject).not.toHaveBeenCalled();
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('creates the user when enabled and domains are unrestricted, stamping config roles onto the returned UserInfo', async () => {
|
|
589
|
+
mockConfig.userHandling.newUserRoles = ['UI', 'Developer'];
|
|
590
|
+
|
|
591
|
+
const user = await verifyUserRecord('new@example.com', 'New', 'User');
|
|
592
|
+
|
|
593
|
+
expect(user).toBeDefined();
|
|
594
|
+
const stamped = user as unknown as { Email: string; UserRoles: Array<{ Role: string; RoleID: string; UserID: string }> };
|
|
595
|
+
expect(stamped.Email).toBe('new@example.com');
|
|
596
|
+
expect(stamped.UserRoles).toEqual([
|
|
597
|
+
{ UserID: entitiesOf('MJ: Users')[0].ID, Role: 'UI', RoleID: 'role-ui' },
|
|
598
|
+
{ UserID: entitiesOf('MJ: Users')[0].ID, Role: 'Developer', RoleID: 'role-dev' },
|
|
599
|
+
]);
|
|
600
|
+
// The new user joins the shared cache so subsequent requests resolve it
|
|
601
|
+
expect(mockCacheUsers.some((u) => u.Email === 'new@example.com')).toBe(true);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('returns undefined when the underlying createNewUser fails', async () => {
|
|
605
|
+
saveBehavior['MJ: Users'] = () => false;
|
|
606
|
+
|
|
607
|
+
const user = await verifyUserRecord('new@example.com', 'New', 'User');
|
|
608
|
+
|
|
609
|
+
expect(user).toBeUndefined();
|
|
610
|
+
expect(mockCacheUsers).toHaveLength(0);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
describe('authorized-domain restrictions', () => {
|
|
615
|
+
beforeEach(() => {
|
|
616
|
+
mockConfig.userHandling.newUserLimitedToAuthorizedDomains = true;
|
|
617
|
+
mockConfig.userHandling.newUserAuthorizedDomains = ['example.com'];
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('creates the user when the email domain is authorized', async () => {
|
|
621
|
+
const user = await verifyUserRecord('new@example.com', 'New', 'User');
|
|
622
|
+
|
|
623
|
+
expect(user).toBeDefined();
|
|
624
|
+
expect(entitiesOf('MJ: Users')).toHaveLength(1);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('does NOT create when the email domain is not authorized', async () => {
|
|
628
|
+
const user = await verifyUserRecord('new@evil.com', 'New', 'User');
|
|
629
|
+
|
|
630
|
+
expect(user).toBeUndefined();
|
|
631
|
+
expect(mockGetEntityObject).not.toHaveBeenCalled();
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('creates when restricted and no request Origin is supplied, if the email domain is authorized', async () => {
|
|
635
|
+
const user = await verifyUserRecord('new@example.com', 'New', 'User', undefined);
|
|
636
|
+
|
|
637
|
+
expect(user).toBeDefined();
|
|
638
|
+
expect(entitiesOf('MJ: Users')).toHaveLength(1);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it('does NOT create when a forged Origin is authorized but the email domain is not', async () => {
|
|
642
|
+
const user = await verifyUserRecord('new@evil.com', 'New', 'User', 'example.com');
|
|
643
|
+
|
|
644
|
+
expect(user).toBeUndefined();
|
|
645
|
+
expect(mockGetEntityObject).not.toHaveBeenCalled();
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('matches email domains case-insensitively', async () => {
|
|
649
|
+
const user = await verifyUserRecord('new@EXAMPLE.COM', 'New', 'User');
|
|
650
|
+
|
|
651
|
+
expect(user).toBeDefined();
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('supports wildcard patterns for email subdomains', async () => {
|
|
655
|
+
mockConfig.userHandling.newUserAuthorizedDomains = ['*.example.com'];
|
|
656
|
+
|
|
657
|
+
const user = await verifyUserRecord('new@mail.example.com', 'New', 'User');
|
|
658
|
+
|
|
659
|
+
expect(user).toBeDefined();
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it('does NOT match the apex against "*.example.com" (pattern is matched in full)', async () => {
|
|
663
|
+
mockConfig.userHandling.newUserAuthorizedDomains = ['*.example.com'];
|
|
664
|
+
|
|
665
|
+
const user = await verifyUserRecord('new@example.com', 'New', 'User');
|
|
666
|
+
|
|
667
|
+
expect(user).toBeUndefined();
|
|
668
|
+
expect(mockGetEntityObject).not.toHaveBeenCalled();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('rejects prefix confusion: "*.example.com" does not match "evilexample.com" (dot is escaped)', async () => {
|
|
672
|
+
mockConfig.userHandling.newUserAuthorizedDomains = ['*.example.com'];
|
|
673
|
+
|
|
674
|
+
const user = await verifyUserRecord('new@evilexample.com', 'New', 'User');
|
|
675
|
+
|
|
676
|
+
expect(user).toBeUndefined();
|
|
677
|
+
expect(mockGetEntityObject).not.toHaveBeenCalled();
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('rejects suffix confusion: the pattern is anchored, so "example.com.evil.com" is not authorized', async () => {
|
|
681
|
+
const user = await verifyUserRecord('new@example.com.evil.com', 'New', 'User');
|
|
682
|
+
|
|
683
|
+
expect(user).toBeUndefined();
|
|
684
|
+
expect(mockGetEntityObject).not.toHaveBeenCalled();
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('rejects "evilexample.com" against the plain "example.com" pattern (anchored at both ends)', async () => {
|
|
688
|
+
const user = await verifyUserRecord('x@evilexample.com', 'New', 'User');
|
|
689
|
+
|
|
690
|
+
expect(user).toBeUndefined();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it('does NOT create when the identity has no email domain (username-only IdP)', async () => {
|
|
694
|
+
const user = await verifyUserRecord('bare-username', 'New', 'User');
|
|
695
|
+
|
|
696
|
+
expect(user).toBeUndefined();
|
|
697
|
+
expect(mockGetEntityObject).not.toHaveBeenCalled();
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
describe('cache-refresh retry', () => {
|
|
702
|
+
it('refreshes the cache once and retries when updateCacheWhenNotFound is set', async () => {
|
|
703
|
+
mockConfig.userHandling.autoCreateNewUsers = false;
|
|
704
|
+
mockConfig.userHandling.updateCacheWhenNotFound = true;
|
|
705
|
+
mockConfig.userHandling.updateCacheWhenNotFoundDelay = 0;
|
|
706
|
+
mockRefresh.mockImplementation(async () => {
|
|
707
|
+
addCacheUser('late@example.com');
|
|
708
|
+
});
|
|
709
|
+
const dataSource = {} as unknown as import('mssql').ConnectionPool;
|
|
710
|
+
|
|
711
|
+
const user = await verifyUserRecord('late@example.com', undefined, undefined, undefined, dataSource);
|
|
712
|
+
|
|
713
|
+
expect(user).toBeDefined();
|
|
714
|
+
expect((user as unknown as { Email: string }).Email).toBe('late@example.com');
|
|
715
|
+
expect(mockRefresh).toHaveBeenCalledTimes(1); // no infinite retry loop
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('gives up after one refresh when the user still is not found', async () => {
|
|
719
|
+
mockConfig.userHandling.autoCreateNewUsers = false;
|
|
720
|
+
mockConfig.userHandling.updateCacheWhenNotFound = true;
|
|
721
|
+
const dataSource = {} as unknown as import('mssql').ConnectionPool;
|
|
722
|
+
|
|
723
|
+
const user = await verifyUserRecord('never@example.com', undefined, undefined, undefined, dataSource);
|
|
724
|
+
|
|
725
|
+
expect(user).toBeUndefined();
|
|
726
|
+
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
});
|