@trailmix-cms/cms 0.7.1 → 0.7.2

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.
@@ -7,9 +7,9 @@ import * as trailmixModels from '@trailmix-cms/models';
7
7
 
8
8
  import { AuthService, AuthResult, GlobalRoleService } from '@/services';
9
9
  import { AccountService } from '@/services/account.service';
10
- import { ApiKeyCollection, SecurityAuditCollection } from '@/collections';
10
+ import { AccountCollection, ApiKeyCollection, SecurityAuditCollection } from '@/collections';
11
11
  import { PROVIDER_SYMBOLS } from '@/constants';
12
- import { type AuthGuardHook, type RequestPrincipal } from '@/types';
12
+ import { type RequestPrincipal } from '@/types';
13
13
 
14
14
  import * as TestUtils from '../../utils';
15
15
 
@@ -21,6 +21,7 @@ jest.mock('@clerk/fastify', () => ({
21
21
  describe('AuthService', () => {
22
22
  let service: AuthService;
23
23
  let accountService: jest.Mocked<AccountService>;
24
+ let accountCollection: jest.Mocked<AccountCollection>;
24
25
  let globalRoleService: jest.Mocked<GlobalRoleService>;
25
26
  let securityAuditCollection: jest.Mocked<SecurityAuditCollection>;
26
27
  let apiKeyCollection: jest.Mocked<ApiKeyCollection>;
@@ -38,6 +39,10 @@ describe('AuthService', () => {
38
39
  upsertAccount: jest.fn(),
39
40
  };
40
41
 
42
+ const mockAccountCollection = {
43
+ findOne: jest.fn(),
44
+ };
45
+
41
46
  const mockGlobalRoleService = {
42
47
  find: jest.fn(),
43
48
  };
@@ -57,6 +62,10 @@ describe('AuthService', () => {
57
62
  provide: AccountService,
58
63
  useValue: mockAccountService,
59
64
  },
65
+ {
66
+ provide: AccountCollection,
67
+ useValue: mockAccountCollection,
68
+ },
60
69
  {
61
70
  provide: GlobalRoleService,
62
71
  useValue: mockGlobalRoleService,
@@ -74,6 +83,7 @@ describe('AuthService', () => {
74
83
 
75
84
  service = module.get<AuthService>(AuthService);
76
85
  accountService = module.get(AccountService);
86
+ accountCollection = module.get(AccountCollection);
77
87
  globalRoleService = module.get(GlobalRoleService);
78
88
  securityAuditCollection = module.get(SecurityAuditCollection);
79
89
  apiKeyCollection = module.get(ApiKeyCollection);
@@ -99,9 +109,12 @@ describe('AuthService', () => {
99
109
  it('returns IsValid when allowAnonymous is true (ensuring anonymous access is allowed)', async () => {
100
110
  const result = await service.validateAuth(
101
111
  null,
102
- true,
103
- [],
104
- [],
112
+ {
113
+ allowAnonymous: true,
114
+ requiredPrincipalTypes: [],
115
+ requiredGlobalRoles: [],
116
+ requiredApiKeyScopes: [],
117
+ },
105
118
  requestUrl
106
119
  );
107
120
 
@@ -112,9 +125,12 @@ describe('AuthService', () => {
112
125
  it('returns Unauthorized when allowAnonymous is false (ensuring anonymous access is blocked)', async () => {
113
126
  const result = await service.validateAuth(
114
127
  null,
115
- false,
116
- [],
117
- [],
128
+ {
129
+ allowAnonymous: false,
130
+ requiredPrincipalTypes: [],
131
+ requiredGlobalRoles: [],
132
+ requiredApiKeyScopes: [],
133
+ },
118
134
  requestUrl
119
135
  );
120
136
 
@@ -127,9 +143,12 @@ describe('AuthService', () => {
127
143
  it('returns IsValid when principal type matches required type (ensuring matching principal types pass)', async () => {
128
144
  const result = await service.validateAuth(
129
145
  accountPrincipal,
130
- false,
131
- [trailmixModels.Principal.Account],
132
- [],
146
+ {
147
+ allowAnonymous: false,
148
+ requiredPrincipalTypes: [trailmixModels.Principal.Account],
149
+ requiredGlobalRoles: [],
150
+ requiredApiKeyScopes: [],
151
+ },
133
152
  requestUrl
134
153
  );
135
154
 
@@ -140,9 +159,12 @@ describe('AuthService', () => {
140
159
  it('returns Forbidden when principal type does not match required type (ensuring non-matching principal types are rejected)', async () => {
141
160
  const result = await service.validateAuth(
142
161
  accountPrincipal,
143
- false,
144
- [trailmixModels.Principal.ApiKey],
145
- [],
162
+ {
163
+ allowAnonymous: false,
164
+ requiredPrincipalTypes: [trailmixModels.Principal.ApiKey],
165
+ requiredGlobalRoles: [],
166
+ requiredApiKeyScopes: [],
167
+ },
146
168
  requestUrl
147
169
  );
148
170
 
@@ -159,9 +181,108 @@ describe('AuthService', () => {
159
181
  it('returns IsValid when no principal types are required (ensuring any principal type passes when none required)', async () => {
160
182
  const result = await service.validateAuth(
161
183
  accountPrincipal,
162
- false,
163
- [],
164
- [],
184
+ {
185
+ allowAnonymous: false,
186
+ requiredPrincipalTypes: [],
187
+ requiredGlobalRoles: [],
188
+ requiredApiKeyScopes: [],
189
+ },
190
+ requestUrl
191
+ );
192
+
193
+ expect(result).toBe(AuthResult.IsValid);
194
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
195
+ });
196
+ });
197
+
198
+ describe('API key scope validation', () => {
199
+ const apiKeyEntity = TestUtils.Entities.createApiKey({
200
+ scope_type: trailmixModels.ApiKeyScope.Account,
201
+ });
202
+ const apiKeyPrincipal: RequestPrincipal = {
203
+ entity: apiKeyEntity,
204
+ principal_type: trailmixModels.Principal.ApiKey,
205
+ };
206
+
207
+ it('returns IsValid when API key scope matches required scope (ensuring matching API key scopes pass)', async () => {
208
+ const result = await service.validateAuth(
209
+ apiKeyPrincipal,
210
+ {
211
+ allowAnonymous: false,
212
+ requiredPrincipalTypes: [],
213
+ requiredGlobalRoles: [],
214
+ requiredApiKeyScopes: [trailmixModels.ApiKeyScope.Account],
215
+ },
216
+ requestUrl
217
+ );
218
+
219
+ expect(result).toBe(AuthResult.IsValid);
220
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
221
+ });
222
+
223
+ it('returns Forbidden when API key scope does not match required scope (ensuring non-matching API key scopes are rejected)', async () => {
224
+ const result = await service.validateAuth(
225
+ apiKeyPrincipal,
226
+ {
227
+ allowAnonymous: false,
228
+ requiredPrincipalTypes: [],
229
+ requiredGlobalRoles: [],
230
+ requiredApiKeyScopes: [trailmixModels.ApiKeyScope.Organization],
231
+ },
232
+ requestUrl
233
+ );
234
+
235
+ expect(result).toBe(AuthResult.Forbidden);
236
+ expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
237
+ event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
238
+ principal_id: apiKeyEntity._id,
239
+ principal_type: trailmixModels.Principal.ApiKey,
240
+ message: `Unauthorized access to ${requestUrl}, required API key scope is not allowed:${trailmixModels.ApiKeyScope.Organization}`,
241
+ source: AuthService.name,
242
+ });
243
+ });
244
+
245
+ it('returns IsValid when no API key scopes are required (ensuring any API key scope passes when none required)', async () => {
246
+ const result = await service.validateAuth(
247
+ apiKeyPrincipal,
248
+ {
249
+ allowAnonymous: false,
250
+ requiredPrincipalTypes: [],
251
+ requiredGlobalRoles: [],
252
+ requiredApiKeyScopes: [],
253
+ },
254
+ requestUrl
255
+ );
256
+
257
+ expect(result).toBe(AuthResult.IsValid);
258
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
259
+ });
260
+
261
+ it('returns IsValid when API key has one of multiple required scopes (ensuring any matching scope passes)', async () => {
262
+ const result = await service.validateAuth(
263
+ apiKeyPrincipal,
264
+ {
265
+ allowAnonymous: false,
266
+ requiredPrincipalTypes: [],
267
+ requiredGlobalRoles: [],
268
+ requiredApiKeyScopes: [trailmixModels.ApiKeyScope.Organization, trailmixModels.ApiKeyScope.Account],
269
+ },
270
+ requestUrl
271
+ );
272
+
273
+ expect(result).toBe(AuthResult.IsValid);
274
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
275
+ });
276
+
277
+ it('does not check API key scopes for account principals (ensuring scope check only applies to API keys)', async () => {
278
+ const result = await service.validateAuth(
279
+ accountPrincipal,
280
+ {
281
+ allowAnonymous: false,
282
+ requiredPrincipalTypes: [],
283
+ requiredGlobalRoles: [],
284
+ requiredApiKeyScopes: [trailmixModels.ApiKeyScope.Account],
285
+ },
165
286
  requestUrl
166
287
  );
167
288
 
@@ -174,9 +295,12 @@ describe('AuthService', () => {
174
295
  it('returns IsValid when no roles are required (ensuring authenticated principals pass when no roles required)', async () => {
175
296
  const result = await service.validateAuth(
176
297
  accountPrincipal,
177
- false,
178
- [],
179
- [],
298
+ {
299
+ allowAnonymous: false,
300
+ requiredPrincipalTypes: [],
301
+ requiredGlobalRoles: [],
302
+ requiredApiKeyScopes: [],
303
+ },
180
304
  requestUrl
181
305
  );
182
306
 
@@ -189,9 +313,12 @@ describe('AuthService', () => {
189
313
 
190
314
  const result = await service.validateAuth(
191
315
  accountPrincipal,
192
- false,
193
- [],
194
- [trailmixModels.RoleValue.User],
316
+ {
317
+ allowAnonymous: false,
318
+ requiredPrincipalTypes: [],
319
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
320
+ requiredApiKeyScopes: [],
321
+ },
195
322
  requestUrl
196
323
  );
197
324
 
@@ -214,9 +341,12 @@ describe('AuthService', () => {
214
341
 
215
342
  const result = await service.validateAuth(
216
343
  accountPrincipal,
217
- false,
218
- [],
219
- [trailmixModels.RoleValue.User],
344
+ {
345
+ allowAnonymous: false,
346
+ requiredPrincipalTypes: [],
347
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
348
+ requiredApiKeyScopes: [],
349
+ },
220
350
  requestUrl
221
351
  );
222
352
 
@@ -234,9 +364,12 @@ describe('AuthService', () => {
234
364
 
235
365
  const result = await service.validateAuth(
236
366
  accountPrincipal,
237
- false,
238
- [],
239
- [trailmixModels.RoleValue.User],
367
+ {
368
+ allowAnonymous: false,
369
+ requiredPrincipalTypes: [],
370
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
371
+ requiredApiKeyScopes: [],
372
+ },
240
373
  requestUrl
241
374
  );
242
375
 
@@ -258,9 +391,12 @@ describe('AuthService', () => {
258
391
 
259
392
  const result = await service.validateAuth(
260
393
  accountPrincipal,
261
- false,
262
- [],
263
- [trailmixModels.RoleValue.User],
394
+ {
395
+ allowAnonymous: false,
396
+ requiredPrincipalTypes: [],
397
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
398
+ requiredApiKeyScopes: [],
399
+ },
264
400
  requestUrl
265
401
  );
266
402
 
@@ -278,9 +414,12 @@ describe('AuthService', () => {
278
414
 
279
415
  const result = await service.validateAuth(
280
416
  accountPrincipal,
281
- false,
282
- [],
283
- [trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.User],
417
+ {
418
+ allowAnonymous: false,
419
+ requiredPrincipalTypes: [],
420
+ requiredGlobalRoles: [trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.User],
421
+ requiredApiKeyScopes: [],
422
+ },
284
423
  requestUrl
285
424
  );
286
425
 
@@ -297,9 +436,12 @@ describe('AuthService', () => {
297
436
 
298
437
  const result = await service.validateAuth(
299
438
  accountPrincipal,
300
- false,
301
- [],
302
- [trailmixModels.RoleValue.User],
439
+ {
440
+ allowAnonymous: false,
441
+ requiredPrincipalTypes: [],
442
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
443
+ requiredApiKeyScopes: [],
444
+ },
303
445
  requestUrl
304
446
  );
305
447
 
@@ -330,9 +472,12 @@ describe('AuthService', () => {
330
472
 
331
473
  const result = await service.validateAuth(
332
474
  accountPrincipal,
333
- false,
334
- [],
335
- [trailmixModels.RoleValue.User],
475
+ {
476
+ allowAnonymous: false,
477
+ requiredPrincipalTypes: [],
478
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
479
+ requiredApiKeyScopes: [],
480
+ },
336
481
  requestUrl
337
482
  );
338
483
 
@@ -344,9 +489,12 @@ describe('AuthService', () => {
344
489
  it('returns IsValid when allowAnonymous is true even with principal (ensuring anonymous flag allows authenticated principals)', async () => {
345
490
  const result = await service.validateAuth(
346
491
  accountPrincipal,
347
- true,
348
- [],
349
- [trailmixModels.RoleValue.User],
492
+ {
493
+ allowAnonymous: true,
494
+ requiredPrincipalTypes: [],
495
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
496
+ requiredApiKeyScopes: [],
497
+ },
350
498
  requestUrl
351
499
  );
352
500
 
@@ -488,6 +636,10 @@ describe('AuthService', () => {
488
636
  provide: AccountService,
489
637
  useValue: { getAccount: jest.fn(), upsertAccount: jest.fn() },
490
638
  },
639
+ {
640
+ provide: AccountCollection,
641
+ useValue: { findOne: jest.fn() },
642
+ },
491
643
  {
492
644
  provide: GlobalRoleService,
493
645
  useValue: { find: jest.fn() },
@@ -603,6 +755,10 @@ describe('AuthService', () => {
603
755
  upsertAccount: jest.fn().mockResolvedValue(TestUtils.Entities.createAccount()),
604
756
  },
605
757
  },
758
+ {
759
+ provide: AccountCollection,
760
+ useValue: { findOne: jest.fn() },
761
+ },
606
762
  {
607
763
  provide: GlobalRoleService,
608
764
  useValue: { find: jest.fn() },
@@ -654,6 +810,10 @@ describe('AuthService', () => {
654
810
  upsertAccount: jest.fn(),
655
811
  },
656
812
  },
813
+ {
814
+ provide: AccountCollection,
815
+ useValue: { findOne: jest.fn() },
816
+ },
657
817
  {
658
818
  provide: GlobalRoleService,
659
819
  useValue: { find: jest.fn() },
@@ -700,6 +860,10 @@ describe('AuthService', () => {
700
860
  upsertAccount: jest.fn().mockResolvedValue(TestUtils.Entities.createAccount()),
701
861
  },
702
862
  },
863
+ {
864
+ provide: AccountCollection,
865
+ useValue: { findOne: jest.fn() },
866
+ },
703
867
  {
704
868
  provide: GlobalRoleService,
705
869
  useValue: { find: jest.fn() },
@@ -749,6 +913,10 @@ describe('AuthService', () => {
749
913
  upsertAccount: jest.fn().mockResolvedValue(TestUtils.Entities.createAccount()),
750
914
  },
751
915
  },
916
+ {
917
+ provide: AccountCollection,
918
+ useValue: { findOne: jest.fn() },
919
+ },
752
920
  {
753
921
  provide: GlobalRoleService,
754
922
  useValue: { find: jest.fn() },
@@ -787,4 +955,82 @@ describe('AuthService', () => {
787
955
  expect(mockAuthGuardHook.onHook).toHaveBeenCalledWith(accountEntity);
788
956
  });
789
957
  });
958
+
959
+ describe('getAccountFromPrincipal', () => {
960
+ it('returns account entity when principal type is Account (ensuring account principals return account directly)', async () => {
961
+ const accountEntity = TestUtils.Entities.createAccount();
962
+ const accountPrincipal: RequestPrincipal = {
963
+ entity: accountEntity,
964
+ principal_type: trailmixModels.Principal.Account,
965
+ };
966
+
967
+ const result = await service.getAccountFromPrincipal(accountPrincipal);
968
+
969
+ expect(result).toEqual(accountEntity);
970
+ expect(accountCollection.findOne).not.toHaveBeenCalled();
971
+ });
972
+
973
+ it('returns account when principal is account-scoped API key (ensuring account-scoped API keys resolve to account)', async () => {
974
+ const accountEntity = TestUtils.Entities.createAccount();
975
+ const apiKeyEntity = TestUtils.Entities.createApiKey({
976
+ scope_type: trailmixModels.ApiKeyScope.Account,
977
+ scope_id: accountEntity._id,
978
+ });
979
+ const apiKeyPrincipal: RequestPrincipal = {
980
+ entity: apiKeyEntity,
981
+ principal_type: trailmixModels.Principal.ApiKey,
982
+ };
983
+
984
+ accountCollection.findOne.mockResolvedValue(accountEntity);
985
+
986
+ const result = await service.getAccountFromPrincipal(apiKeyPrincipal);
987
+
988
+ expect(result).toEqual(accountEntity);
989
+ expect(accountCollection.findOne).toHaveBeenCalledWith({ _id: accountEntity._id });
990
+ });
991
+
992
+ it('throws error when API key is not account-scoped (ensuring non-account-scoped API keys are rejected)', async () => {
993
+ const apiKeyEntity = TestUtils.Entities.createApiKey({
994
+ scope_type: trailmixModels.ApiKeyScope.Global,
995
+ });
996
+ const apiKeyPrincipal: RequestPrincipal = {
997
+ entity: apiKeyEntity,
998
+ principal_type: trailmixModels.Principal.ApiKey,
999
+ };
1000
+
1001
+ await expect(service.getAccountFromPrincipal(apiKeyPrincipal)).rejects.toThrow('API key is not account-scoped');
1002
+ expect(accountCollection.findOne).not.toHaveBeenCalled();
1003
+ });
1004
+
1005
+ it('throws error when account-scoped API key references non-existent account (ensuring missing accounts are rejected)', async () => {
1006
+ const accountEntity = TestUtils.Entities.createAccount();
1007
+ const apiKeyEntity = TestUtils.Entities.createApiKey({
1008
+ scope_type: trailmixModels.ApiKeyScope.Account,
1009
+ scope_id: accountEntity._id,
1010
+ });
1011
+ const apiKeyPrincipal: RequestPrincipal = {
1012
+ entity: apiKeyEntity,
1013
+ principal_type: trailmixModels.Principal.ApiKey,
1014
+ };
1015
+
1016
+ accountCollection.findOne.mockResolvedValue(null);
1017
+
1018
+ await expect(service.getAccountFromPrincipal(apiKeyPrincipal)).rejects.toThrow('Account not found');
1019
+ expect(accountCollection.findOne).toHaveBeenCalledWith({ _id: accountEntity._id });
1020
+ });
1021
+
1022
+ it('throws error when API key has Organization scope (ensuring organization-scoped API keys are rejected)', async () => {
1023
+ const apiKeyEntity = TestUtils.Entities.createApiKey({
1024
+ scope_type: trailmixModels.ApiKeyScope.Organization,
1025
+ scope_id: TestUtils.Entities.createAccount()._id,
1026
+ });
1027
+ const apiKeyPrincipal: RequestPrincipal = {
1028
+ entity: apiKeyEntity,
1029
+ principal_type: trailmixModels.Principal.ApiKey,
1030
+ };
1031
+
1032
+ await expect(service.getAccountFromPrincipal(apiKeyPrincipal)).rejects.toThrow('API key is not account-scoped');
1033
+ expect(accountCollection.findOne).not.toHaveBeenCalled();
1034
+ });
1035
+ });
790
1036
  });