@trailmix-cms/cms 0.4.4 → 0.7.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.
Files changed (266) hide show
  1. package/dist/auth.guard.d.ts +5 -13
  2. package/dist/auth.guard.d.ts.map +1 -1
  3. package/dist/auth.guard.js +23 -91
  4. package/dist/auth.guard.js.map +1 -1
  5. package/dist/collections/account.collection.d.ts +5 -3
  6. package/dist/collections/account.collection.d.ts.map +1 -1
  7. package/dist/collections/account.collection.js +15 -8
  8. package/dist/collections/account.collection.js.map +1 -1
  9. package/dist/collections/api-key.collection.d.ts +54 -0
  10. package/dist/collections/api-key.collection.d.ts.map +1 -0
  11. package/dist/collections/api-key.collection.js +142 -0
  12. package/dist/collections/api-key.collection.js.map +1 -0
  13. package/dist/collections/index.d.ts +4 -2
  14. package/dist/collections/index.d.ts.map +1 -1
  15. package/dist/collections/index.js +9 -5
  16. package/dist/collections/index.js.map +1 -1
  17. package/dist/collections/organization.collection.d.ts +20 -0
  18. package/dist/collections/organization.collection.d.ts.map +1 -0
  19. package/dist/collections/{file.collection.js → organization.collection.js} +17 -17
  20. package/dist/collections/organization.collection.js.map +1 -0
  21. package/dist/collections/role.collection.d.ts +32 -0
  22. package/dist/collections/role.collection.d.ts.map +1 -0
  23. package/dist/collections/role.collection.js +90 -0
  24. package/dist/collections/role.collection.js.map +1 -0
  25. package/dist/collections/security-audit.collection.d.ts +30 -0
  26. package/dist/collections/security-audit.collection.d.ts.map +1 -0
  27. package/dist/collections/security-audit.collection.js +79 -0
  28. package/dist/collections/security-audit.collection.js.map +1 -0
  29. package/dist/constants/cms-collection-names.d.ts +4 -2
  30. package/dist/constants/cms-collection-names.d.ts.map +1 -1
  31. package/dist/constants/cms-collection-names.js +4 -2
  32. package/dist/constants/cms-collection-names.js.map +1 -1
  33. package/dist/constants/provider-symbols.d.ts +10 -12
  34. package/dist/constants/provider-symbols.d.ts.map +1 -1
  35. package/dist/constants/provider-symbols.js +10 -12
  36. package/dist/constants/provider-symbols.js.map +1 -1
  37. package/dist/controllers/account.controller.d.ts +11 -15
  38. package/dist/controllers/account.controller.d.ts.map +1 -1
  39. package/dist/controllers/account.controller.js +69 -13
  40. package/dist/controllers/account.controller.js.map +1 -1
  41. package/dist/controllers/api-keys.controller.d.ts +13 -0
  42. package/dist/controllers/api-keys.controller.d.ts.map +1 -0
  43. package/dist/controllers/api-keys.controller.js +125 -0
  44. package/dist/controllers/api-keys.controller.js.map +1 -0
  45. package/dist/controllers/audit.controller.d.ts.map +1 -1
  46. package/dist/controllers/audit.controller.js +3 -3
  47. package/dist/controllers/audit.controller.js.map +1 -1
  48. package/dist/controllers/audits.controller.d.ts +10 -0
  49. package/dist/controllers/audits.controller.d.ts.map +1 -0
  50. package/dist/controllers/audits.controller.js +107 -0
  51. package/dist/controllers/audits.controller.js.map +1 -0
  52. package/dist/controllers/global-roles.controller.d.ts +16 -0
  53. package/dist/controllers/global-roles.controller.d.ts.map +1 -0
  54. package/dist/controllers/global-roles.controller.js +137 -0
  55. package/dist/controllers/global-roles.controller.js.map +1 -0
  56. package/dist/controllers/index.d.ts +6 -1
  57. package/dist/controllers/index.d.ts.map +1 -1
  58. package/dist/controllers/index.js +6 -1
  59. package/dist/controllers/index.js.map +1 -1
  60. package/dist/controllers/organization-roles.controller.d.ts +16 -0
  61. package/dist/controllers/organization-roles.controller.d.ts.map +1 -0
  62. package/dist/controllers/organization-roles.controller.js +145 -0
  63. package/dist/controllers/organization-roles.controller.js.map +1 -0
  64. package/dist/controllers/organizations.controller.d.ts +65 -0
  65. package/dist/controllers/organizations.controller.d.ts.map +1 -0
  66. package/dist/controllers/organizations.controller.js +140 -0
  67. package/dist/controllers/organizations.controller.js.map +1 -0
  68. package/dist/controllers/security-audits.controller.d.ts +11 -0
  69. package/dist/controllers/security-audits.controller.d.ts.map +1 -0
  70. package/dist/controllers/security-audits.controller.js +130 -0
  71. package/dist/controllers/security-audits.controller.js.map +1 -0
  72. package/dist/decorators/account.decorator.d.ts +1 -3
  73. package/dist/decorators/account.decorator.d.ts.map +1 -1
  74. package/dist/decorators/account.decorator.js +3 -10
  75. package/dist/decorators/account.decorator.js.map +1 -1
  76. package/dist/decorators/audit-context.decorator.d.ts +6 -0
  77. package/dist/decorators/audit-context.decorator.d.ts.map +1 -1
  78. package/dist/decorators/audit-context.decorator.js +12 -3
  79. package/dist/decorators/audit-context.decorator.js.map +1 -1
  80. package/dist/decorators/auth.decorator.d.ts +5 -3
  81. package/dist/decorators/auth.decorator.d.ts.map +1 -1
  82. package/dist/decorators/auth.decorator.js +38 -3
  83. package/dist/decorators/auth.decorator.js.map +1 -1
  84. package/dist/decorators/index.d.ts +4 -0
  85. package/dist/decorators/index.d.ts.map +1 -0
  86. package/dist/decorators/index.js +20 -0
  87. package/dist/decorators/index.js.map +1 -0
  88. package/dist/dto/account.dto.d.ts +33 -0
  89. package/dist/dto/account.dto.d.ts.map +1 -0
  90. package/dist/dto/account.dto.js +14 -0
  91. package/dist/dto/account.dto.js.map +1 -0
  92. package/dist/dto/api-key.dto.d.ts +89 -0
  93. package/dist/dto/api-key.dto.d.ts.map +1 -0
  94. package/dist/dto/api-key.dto.js +27 -0
  95. package/dist/dto/api-key.dto.js.map +1 -0
  96. package/dist/dto/audit.dto.d.ts +11 -5
  97. package/dist/dto/audit.dto.d.ts.map +1 -1
  98. package/dist/dto/audit.dto.js +1 -1
  99. package/dist/dto/audit.dto.js.map +1 -1
  100. package/dist/dto/global-role.dto.d.ts +99 -0
  101. package/dist/dto/global-role.dto.d.ts.map +1 -0
  102. package/dist/dto/global-role.dto.js +26 -0
  103. package/dist/dto/global-role.dto.js.map +1 -0
  104. package/dist/dto/organization-role.dto.d.ts +107 -0
  105. package/dist/dto/organization-role.dto.d.ts.map +1 -0
  106. package/dist/dto/organization-role.dto.js +26 -0
  107. package/dist/dto/organization-role.dto.js.map +1 -0
  108. package/dist/dto/organization.dto.d.ts +57 -0
  109. package/dist/dto/organization.dto.d.ts.map +1 -0
  110. package/dist/dto/organization.dto.js +32 -0
  111. package/dist/dto/organization.dto.js.map +1 -0
  112. package/dist/dto/security-audit.dto.d.ts +95 -0
  113. package/dist/dto/security-audit.dto.d.ts.map +1 -0
  114. package/dist/dto/security-audit.dto.js +26 -0
  115. package/dist/dto/security-audit.dto.js.map +1 -0
  116. package/dist/index.d.ts +7 -2
  117. package/dist/index.d.ts.map +1 -1
  118. package/dist/index.js +8 -3
  119. package/dist/index.js.map +1 -1
  120. package/dist/managers/global-role.manager.d.ts +42 -0
  121. package/dist/managers/global-role.manager.d.ts.map +1 -0
  122. package/dist/managers/global-role.manager.js +117 -0
  123. package/dist/managers/global-role.manager.js.map +1 -0
  124. package/dist/managers/index.d.ts +4 -0
  125. package/dist/managers/index.d.ts.map +1 -0
  126. package/dist/managers/index.js +20 -0
  127. package/dist/managers/index.js.map +1 -0
  128. package/dist/managers/organization-role.manager.d.ts +47 -0
  129. package/dist/managers/organization-role.manager.d.ts.map +1 -0
  130. package/dist/managers/organization-role.manager.js +218 -0
  131. package/dist/managers/organization-role.manager.js.map +1 -0
  132. package/dist/managers/organization.manager.d.ts +39 -0
  133. package/dist/managers/organization.manager.d.ts.map +1 -0
  134. package/dist/managers/organization.manager.js +196 -0
  135. package/dist/managers/organization.manager.js.map +1 -0
  136. package/dist/module.d.ts +92 -0
  137. package/dist/module.d.ts.map +1 -0
  138. package/dist/module.js +137 -0
  139. package/dist/module.js.map +1 -0
  140. package/dist/pipes/api-key.pipe.d.ts +8 -0
  141. package/dist/pipes/api-key.pipe.d.ts.map +1 -0
  142. package/dist/pipes/api-key.pipe.js +28 -0
  143. package/dist/pipes/api-key.pipe.js.map +1 -0
  144. package/dist/pipes/organization.pipe.d.ts +8 -0
  145. package/dist/pipes/organization.pipe.d.ts.map +1 -0
  146. package/dist/pipes/organization.pipe.js +28 -0
  147. package/dist/pipes/organization.pipe.js.map +1 -0
  148. package/dist/pipes/role.pipe.d.ts +8 -0
  149. package/dist/pipes/{file.pipe.d.ts.map → role.pipe.d.ts.map} +1 -1
  150. package/dist/pipes/{file.pipe.js → role.pipe.js} +8 -8
  151. package/dist/pipes/{file.pipe.js.map → role.pipe.js.map} +1 -1
  152. package/dist/services/account.service.d.ts +0 -2
  153. package/dist/services/account.service.d.ts.map +1 -1
  154. package/dist/services/account.service.js +1 -37
  155. package/dist/services/account.service.js.map +1 -1
  156. package/dist/services/api-key.service.d.ts +42 -0
  157. package/dist/services/api-key.service.d.ts.map +1 -0
  158. package/dist/services/api-key.service.js +306 -0
  159. package/dist/services/api-key.service.js.map +1 -0
  160. package/dist/services/auth.service.d.ts +40 -0
  161. package/dist/services/auth.service.d.ts.map +1 -0
  162. package/dist/services/auth.service.js +227 -0
  163. package/dist/services/auth.service.js.map +1 -0
  164. package/dist/services/authorization.service.d.ts +44 -9
  165. package/dist/services/authorization.service.d.ts.map +1 -1
  166. package/dist/services/authorization.service.js +107 -41
  167. package/dist/services/authorization.service.js.map +1 -1
  168. package/dist/services/feature.service.d.ts +23 -0
  169. package/dist/services/feature.service.d.ts.map +1 -0
  170. package/dist/services/feature.service.js +49 -0
  171. package/dist/services/feature.service.js.map +1 -0
  172. package/dist/services/global-role.service.d.ts +17 -0
  173. package/dist/services/global-role.service.d.ts.map +1 -0
  174. package/dist/services/global-role.service.js +99 -0
  175. package/dist/services/global-role.service.js.map +1 -0
  176. package/dist/services/index.d.ts +9 -0
  177. package/dist/services/index.d.ts.map +1 -0
  178. package/dist/services/index.js +25 -0
  179. package/dist/services/index.js.map +1 -0
  180. package/dist/services/organization-role.service.d.ts +33 -0
  181. package/dist/services/organization-role.service.d.ts.map +1 -0
  182. package/dist/services/organization-role.service.js +102 -0
  183. package/dist/services/organization-role.service.js.map +1 -0
  184. package/dist/services/organization.service.d.ts +29 -0
  185. package/dist/services/organization.service.d.ts.map +1 -0
  186. package/dist/services/organization.service.js +95 -0
  187. package/dist/services/organization.service.js.map +1 -0
  188. package/dist/types/feature-config.d.ts +9 -0
  189. package/dist/types/feature-config.d.ts.map +1 -0
  190. package/dist/types/feature-config.js +3 -0
  191. package/dist/types/feature-config.js.map +1 -0
  192. package/dist/types/hooks/auth-guard-hook.d.ts.map +1 -0
  193. package/dist/types/hooks/auth-guard-hook.js.map +1 -0
  194. package/dist/types/hooks/index.d.ts +3 -0
  195. package/dist/types/hooks/index.d.ts.map +1 -0
  196. package/dist/types/hooks/index.js +19 -0
  197. package/dist/types/hooks/index.js.map +1 -0
  198. package/dist/types/hooks/organization-delete-hook.d.ts +20 -0
  199. package/dist/types/hooks/organization-delete-hook.d.ts.map +1 -0
  200. package/dist/types/hooks/organization-delete-hook.js +3 -0
  201. package/dist/types/hooks/organization-delete-hook.js.map +1 -0
  202. package/dist/types/index.d.ts +5 -0
  203. package/dist/types/index.d.ts.map +1 -0
  204. package/dist/types/index.js +21 -0
  205. package/dist/types/index.js.map +1 -0
  206. package/dist/types/request-principal.d.ts +9 -0
  207. package/dist/types/request-principal.d.ts.map +1 -0
  208. package/dist/types/request-principal.js +3 -0
  209. package/dist/types/request-principal.js.map +1 -0
  210. package/dist/utils/provider-helpers.d.ts +6 -1
  211. package/dist/utils/provider-helpers.d.ts.map +1 -1
  212. package/dist/utils/provider-helpers.js +11 -1
  213. package/dist/utils/provider-helpers.js.map +1 -1
  214. package/package.json +52 -17
  215. package/test/unit/collections/api-key.collection.spec.ts +416 -0
  216. package/test/unit/managers/global-role.manager.spec.ts +269 -0
  217. package/test/unit/managers/organization-role.manager.spec.ts +632 -0
  218. package/test/unit/managers/organization.manager.spec.ts +395 -0
  219. package/test/unit/module.spec.ts +596 -0
  220. package/test/unit/services/account.service.spec.ts +90 -0
  221. package/test/unit/services/api-key.service.spec.ts +1244 -0
  222. package/test/unit/services/auth.service.spec.ts +790 -0
  223. package/test/unit/services/authorization.service.spec.ts +636 -0
  224. package/test/unit/services/feature.service.spec.ts +56 -0
  225. package/test/unit/services/global-role.service.spec.ts +289 -0
  226. package/test/unit/services/organization-role.service.spec.ts +300 -0
  227. package/test/unit/services/organization.service.spec.ts +385 -0
  228. package/test/utils/auth-guard.ts +114 -0
  229. package/test/utils/base.ts +16 -0
  230. package/test/utils/entities/account.ts +13 -0
  231. package/test/utils/entities/api-key.ts +15 -0
  232. package/test/utils/entities/audit.ts +18 -0
  233. package/test/utils/entities/index.ts +6 -0
  234. package/test/utils/entities/mapping.ts +20 -0
  235. package/test/utils/entities/organization.ts +13 -0
  236. package/test/utils/entities/role.ts +21 -0
  237. package/test/utils/entities/security-audit.ts +16 -0
  238. package/test/utils/index.ts +4 -0
  239. package/test/utils/models/audit-context.ts +10 -0
  240. package/test/utils/models/authorization.ts +7 -0
  241. package/test/utils/models/global-role.ts +22 -0
  242. package/test/utils/models/index.ts +5 -0
  243. package/test/utils/models/organization-role.ts +23 -0
  244. package/test/utils/models/publishable.ts +7 -0
  245. package/tsconfig.build.json +36 -0
  246. package/tsconfig.build.tsbuildinfo +1 -0
  247. package/dist/auth-guard-hook.d.ts.map +0 -1
  248. package/dist/auth-guard-hook.js.map +0 -1
  249. package/dist/cms.module.d.ts +0 -8
  250. package/dist/cms.module.d.ts.map +0 -1
  251. package/dist/cms.module.js +0 -44
  252. package/dist/cms.module.js.map +0 -1
  253. package/dist/cms.providers.d.ts +0 -120
  254. package/dist/cms.providers.d.ts.map +0 -1
  255. package/dist/cms.providers.js +0 -126
  256. package/dist/cms.providers.js.map +0 -1
  257. package/dist/collections/file.collection.d.ts +0 -21
  258. package/dist/collections/file.collection.d.ts.map +0 -1
  259. package/dist/collections/file.collection.js.map +0 -1
  260. package/dist/collections/text.collection.d.ts +0 -20
  261. package/dist/collections/text.collection.d.ts.map +0 -1
  262. package/dist/collections/text.collection.js +0 -56
  263. package/dist/collections/text.collection.js.map +0 -1
  264. package/dist/pipes/file.pipe.d.ts +0 -8
  265. /package/dist/{auth-guard-hook.d.ts → types/hooks/auth-guard-hook.d.ts} +0 -0
  266. /package/dist/{auth-guard-hook.js → types/hooks/auth-guard-hook.js} +0 -0
@@ -0,0 +1,636 @@
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { Logger } from '@nestjs/common';
3
+ import { ObjectId } from 'mongodb';
4
+
5
+ import * as trailmixModels from '@trailmix-cms/models';
6
+
7
+ import { AuthorizationService, GlobalRoleService, OrganizationRoleService } from '@/services';
8
+ import { SecurityAuditCollection } from '@/collections/security-audit.collection';
9
+ import { RequestPrincipal } from '@/types';
10
+
11
+ import * as TestUtils from '../../utils';
12
+
13
+ describe('AuthorizationService', () => {
14
+ let service: AuthorizationService;
15
+ let organizationRoleService: jest.Mocked<OrganizationRoleService>;
16
+ let globalRoleService: jest.Mocked<GlobalRoleService>;
17
+ let securityAuditCollection: jest.Mocked<SecurityAuditCollection>;
18
+
19
+ beforeEach(async () => {
20
+ // Mock Logger methods to prevent console output during tests
21
+ jest.spyOn(Logger.prototype, 'log').mockImplementation();
22
+ jest.spyOn(Logger.prototype, 'error').mockImplementation();
23
+ jest.spyOn(Logger.prototype, 'warn').mockImplementation();
24
+ jest.spyOn(Logger.prototype, 'debug').mockImplementation();
25
+ jest.spyOn(Logger.prototype, 'verbose').mockImplementation();
26
+
27
+ const mockOrganizationRoleService = {
28
+ find: jest.fn(),
29
+ findOne: jest.fn(),
30
+ };
31
+
32
+ const mockGlobalRoleService = {
33
+ findOne: jest.fn(),
34
+ find: jest.fn(),
35
+ };
36
+
37
+ const mockSecurityAuditCollection = {
38
+ insertOne: jest.fn().mockResolvedValue(undefined),
39
+ };
40
+
41
+ const module: TestingModule = await Test.createTestingModule({
42
+ providers: [
43
+ AuthorizationService,
44
+ {
45
+ provide: OrganizationRoleService,
46
+ useValue: mockOrganizationRoleService,
47
+ },
48
+ {
49
+ provide: GlobalRoleService,
50
+ useValue: mockGlobalRoleService,
51
+ },
52
+ {
53
+ provide: SecurityAuditCollection,
54
+ useValue: mockSecurityAuditCollection,
55
+ },
56
+ ],
57
+ }).compile();
58
+
59
+ service = module.get<AuthorizationService>(AuthorizationService);
60
+ organizationRoleService = module.get(OrganizationRoleService);
61
+ globalRoleService = module.get(GlobalRoleService);
62
+ securityAuditCollection = module.get(SecurityAuditCollection);
63
+ });
64
+
65
+ afterEach(() => {
66
+ jest.clearAllMocks();
67
+ });
68
+
69
+ afterAll(() => {
70
+ // Restore Logger methods after all tests
71
+ jest.restoreAllMocks();
72
+ });
73
+
74
+ describe('isGlobalAdmin', () => {
75
+ it('returns true when principal has Admin role (ensuring Admin role grants global admin access)', async () => {
76
+ const principalId = new ObjectId();
77
+ const principalType = trailmixModels.Principal.Account;
78
+ const adminRole = TestUtils.Models.createGlobalRoleModel({
79
+ principal_id: principalId,
80
+ principal_type: principalType,
81
+ role: trailmixModels.RoleValue.Admin,
82
+ });
83
+
84
+ globalRoleService.findOne.mockResolvedValue(adminRole);
85
+
86
+ const result = await service.isGlobalAdmin(principalId, principalType);
87
+
88
+ expect(result).toBe(true);
89
+ expect(globalRoleService.findOne).toHaveBeenCalledWith({
90
+ principal_id: principalId,
91
+ principal_type: principalType,
92
+ role: trailmixModels.RoleValue.Admin,
93
+ });
94
+ });
95
+
96
+ it('returns false when principal does not have Admin role (ensuring non-admin roles do not grant global admin access)', async () => {
97
+ const principalId = new ObjectId();
98
+ const principalType = trailmixModels.Principal.Account;
99
+
100
+ globalRoleService.findOne.mockResolvedValue(null);
101
+
102
+ const result = await service.isGlobalAdmin(principalId, principalType);
103
+
104
+ expect(result).toBe(false);
105
+ expect(globalRoleService.findOne).toHaveBeenCalledWith({
106
+ principal_id: principalId,
107
+ principal_type: principalType,
108
+ role: trailmixModels.RoleValue.Admin,
109
+ });
110
+ });
111
+
112
+ it('returns false when principal has no global roles (ensuring missing roles do not grant global admin access)', async () => {
113
+ const principalId = new ObjectId();
114
+ const principalType = trailmixModels.Principal.Account;
115
+
116
+ globalRoleService.findOne.mockResolvedValue(null);
117
+
118
+ const result = await service.isGlobalAdmin(principalId, principalType);
119
+
120
+ expect(result).toBe(false);
121
+ expect(globalRoleService.findOne).toHaveBeenCalledWith({
122
+ principal_id: principalId,
123
+ principal_type: principalType,
124
+ role: trailmixModels.RoleValue.Admin,
125
+ });
126
+ });
127
+
128
+ it('returns true when principal has Admin role (ensuring Admin role is detected)', async () => {
129
+ const principalId = new ObjectId();
130
+ const principalType = trailmixModels.Principal.Account;
131
+ const adminRole = TestUtils.Models.createGlobalRoleModel({
132
+ principal_id: principalId,
133
+ principal_type: principalType,
134
+ role: trailmixModels.RoleValue.Admin,
135
+ });
136
+
137
+ globalRoleService.findOne.mockResolvedValue(adminRole);
138
+
139
+ const result = await service.isGlobalAdmin(principalId, principalType);
140
+
141
+ expect(result).toBe(true);
142
+ });
143
+
144
+ it('works correctly for ApiKey principal type (ensuring ApiKey principals can be global admins)', async () => {
145
+ const principalId = new ObjectId();
146
+ const principalType = trailmixModels.Principal.ApiKey;
147
+ const adminRole = TestUtils.Models.createGlobalRoleModel({
148
+ principal_id: principalId,
149
+ principal_type: principalType,
150
+ role: trailmixModels.RoleValue.Admin,
151
+ });
152
+
153
+ globalRoleService.findOne.mockResolvedValue(adminRole);
154
+
155
+ const result = await service.isGlobalAdmin(principalId, principalType);
156
+
157
+ expect(result).toBe(true);
158
+ expect(globalRoleService.findOne).toHaveBeenCalledWith({
159
+ principal_id: principalId,
160
+ principal_type: principalType,
161
+ role: trailmixModels.RoleValue.Admin,
162
+ });
163
+ });
164
+ });
165
+
166
+ describe('resolveOrganizationAuthorization', () => {
167
+ const principalId = new ObjectId();
168
+ const accountEntity = TestUtils.Entities.createAccount({ _id: principalId });
169
+ const accountPrincipal: RequestPrincipal = {
170
+ principal_type: trailmixModels.Principal.Account,
171
+ entity: accountEntity,
172
+ };
173
+ const organizationId = new ObjectId();
174
+
175
+ it('returns hasAccess true when principal is a global admin (ensuring global admins have access to all organizations)', async () => {
176
+ const adminGlobalRole = TestUtils.Models.createGlobalRoleModel({
177
+ principal_id: principalId,
178
+ principal_type: trailmixModels.Principal.Account,
179
+ role: trailmixModels.RoleValue.Admin,
180
+ });
181
+ globalRoleService.find.mockResolvedValue([adminGlobalRole]);
182
+ organizationRoleService.find.mockResolvedValue([]);
183
+
184
+ const result = await service.resolveOrganizationAuthorization({
185
+ principal: accountPrincipal,
186
+ rolesAllowList: [trailmixModels.RoleValue.Admin],
187
+ principalTypeAllowList: [trailmixModels.Principal.Account],
188
+ organizationId,
189
+ });
190
+
191
+ expect(result.hasAccess).toBe(true);
192
+ expect(result.isGlobalAdmin).toBe(true);
193
+ expect(result.globalRoles).toEqual([adminGlobalRole]);
194
+ expect(result.organizationRoles).toEqual([]);
195
+ expect(globalRoleService.find).toHaveBeenCalledWith({
196
+ principal_id: principalId,
197
+ principal_type: trailmixModels.Principal.Account,
198
+ });
199
+ });
200
+
201
+ it('returns hasAccess true when Account principal has matching role (ensuring Account principals with matching roles have access)', async () => {
202
+ globalRoleService.find.mockResolvedValue([]);
203
+ const organizationRole = TestUtils.Models.createOrganizationRoleModel({
204
+ principal_id: principalId,
205
+ principal_type: trailmixModels.Principal.Account,
206
+ organization_id: organizationId,
207
+ role: trailmixModels.RoleValue.Admin,
208
+ });
209
+ organizationRoleService.find.mockResolvedValue([organizationRole]);
210
+
211
+ const result = await service.resolveOrganizationAuthorization({
212
+ principal: accountPrincipal,
213
+ rolesAllowList: [trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.Owner],
214
+ principalTypeAllowList: [trailmixModels.Principal.Account],
215
+ organizationId,
216
+ });
217
+
218
+ expect(result.hasAccess).toBe(true);
219
+ expect(result.isGlobalAdmin).toBe(false);
220
+ expect(result.organizationRoles).toEqual([organizationRole]);
221
+ });
222
+
223
+ it('returns hasAccess false when Account principal has non-matching role (ensuring non-matching roles are rejected)', async () => {
224
+ globalRoleService.find.mockResolvedValue([]);
225
+ const organizationRole = TestUtils.Models.createOrganizationRoleModel({
226
+ principal_id: principalId,
227
+ principal_type: trailmixModels.Principal.Account,
228
+ organization_id: organizationId,
229
+ role: trailmixModels.RoleValue.User,
230
+ });
231
+ organizationRoleService.find.mockResolvedValue([organizationRole]);
232
+
233
+ const result = await service.resolveOrganizationAuthorization({
234
+ principal: accountPrincipal,
235
+ rolesAllowList: [trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.Owner],
236
+ principalTypeAllowList: [trailmixModels.Principal.Account],
237
+ organizationId,
238
+ });
239
+
240
+ expect(result.hasAccess).toBe(false);
241
+ expect(result.isGlobalAdmin).toBe(false);
242
+ expect(result.organizationRoles).toEqual([organizationRole]);
243
+ });
244
+
245
+ it('returns hasAccess true when ApiKey principal has matching role and is in principalTypeAllowList (ensuring ApiKey principals can have access when allowed)', async () => {
246
+ const apiKeyEntity = TestUtils.Entities.createApiKey();
247
+ const apiKeyPrincipal: RequestPrincipal = {
248
+ principal_type: trailmixModels.Principal.ApiKey,
249
+ entity: apiKeyEntity,
250
+ };
251
+ globalRoleService.find.mockResolvedValue([]);
252
+ const organizationRole = TestUtils.Models.createOrganizationRoleModel({
253
+ principal_id: apiKeyEntity._id,
254
+ principal_type: trailmixModels.Principal.ApiKey,
255
+ organization_id: organizationId,
256
+ role: trailmixModels.RoleValue.Admin,
257
+ });
258
+ organizationRoleService.find.mockResolvedValue([organizationRole]);
259
+
260
+ const result = await service.resolveOrganizationAuthorization({
261
+ principal: apiKeyPrincipal,
262
+ rolesAllowList: [trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.Owner],
263
+ principalTypeAllowList: [trailmixModels.Principal.Account, trailmixModels.Principal.ApiKey],
264
+ organizationId,
265
+ });
266
+
267
+ expect(result.hasAccess).toBe(true);
268
+ expect(result.isGlobalAdmin).toBe(false);
269
+ expect(result.organizationRoles).toEqual([organizationRole]);
270
+ });
271
+
272
+ it('returns hasAccess false when ApiKey principal is not in principalTypeAllowList (ensuring ApiKey principals are rejected when not allowed)', async () => {
273
+ const apiKeyEntity = TestUtils.Entities.createApiKey();
274
+ const apiKeyPrincipal: RequestPrincipal = {
275
+ principal_type: trailmixModels.Principal.ApiKey,
276
+ entity: apiKeyEntity,
277
+ };
278
+ globalRoleService.find.mockResolvedValue([]);
279
+ const organizationRole = TestUtils.Models.createOrganizationRoleModel({
280
+ principal_id: apiKeyEntity._id,
281
+ principal_type: trailmixModels.Principal.ApiKey,
282
+ organization_id: organizationId,
283
+ role: trailmixModels.RoleValue.Admin,
284
+ });
285
+ organizationRoleService.find.mockResolvedValue([organizationRole]);
286
+
287
+ const result = await service.resolveOrganizationAuthorization({
288
+ principal: apiKeyPrincipal,
289
+ rolesAllowList: [trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.Owner],
290
+ principalTypeAllowList: [trailmixModels.Principal.Account],
291
+ organizationId,
292
+ });
293
+
294
+ expect(result.hasAccess).toBe(false);
295
+ expect(result.isGlobalAdmin).toBe(false);
296
+ expect(result.organizationRoles).toEqual([organizationRole]);
297
+ });
298
+
299
+ it('returns hasAccess false when principal has no organization roles (ensuring missing roles are rejected)', async () => {
300
+ globalRoleService.find.mockResolvedValue([]);
301
+ organizationRoleService.find.mockResolvedValue([]);
302
+
303
+ const result = await service.resolveOrganizationAuthorization({
304
+ principal: accountPrincipal,
305
+ rolesAllowList: [trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.Owner],
306
+ principalTypeAllowList: [trailmixModels.Principal.Account],
307
+ organizationId,
308
+ });
309
+
310
+ expect(result.hasAccess).toBe(false);
311
+ expect(result.isGlobalAdmin).toBe(false);
312
+ expect(result.organizationRoles).toEqual([]);
313
+ });
314
+
315
+ it('returns hasAccess true when principal has role matching one in rolesAllowList array (ensuring array role matching works)', async () => {
316
+ globalRoleService.find.mockResolvedValue([]);
317
+ const organizationRole = TestUtils.Models.createOrganizationRoleModel({
318
+ principal_id: principalId,
319
+ principal_type: trailmixModels.Principal.Account,
320
+ organization_id: organizationId,
321
+ role: trailmixModels.RoleValue.Owner,
322
+ });
323
+ organizationRoleService.find.mockResolvedValue([organizationRole]);
324
+
325
+ const result = await service.resolveOrganizationAuthorization({
326
+ principal: accountPrincipal,
327
+ rolesAllowList: [trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.Owner],
328
+ principalTypeAllowList: [trailmixModels.Principal.Account],
329
+ organizationId,
330
+ });
331
+
332
+ expect(result.hasAccess).toBe(true);
333
+ expect(result.isGlobalAdmin).toBe(false);
334
+ });
335
+ });
336
+
337
+ describe('authorizeApiKeyAccessForPrincipal', () => {
338
+ const principalId = new ObjectId();
339
+ const accountEntity = TestUtils.Entities.createAccount({ _id: principalId });
340
+ const accountPrincipal: RequestPrincipal = {
341
+ principal_type: trailmixModels.Principal.Account,
342
+ entity: accountEntity,
343
+ };
344
+
345
+ describe('Global scope', () => {
346
+ it('returns true when principal is a global admin (ensuring global admins can access global-scoped API keys)', async () => {
347
+ globalRoleService.findOne.mockResolvedValue(
348
+ TestUtils.Models.createGlobalRoleModel({
349
+ principal_id: principalId,
350
+ principal_type: trailmixModels.Principal.Account,
351
+ role: trailmixModels.RoleValue.Admin,
352
+ }),
353
+ );
354
+
355
+ const result = await service.authorizeApiKeyAccessForPrincipal(
356
+ accountPrincipal,
357
+ trailmixModels.ApiKeyScope.Global
358
+ );
359
+
360
+ expect(result).toBe(true);
361
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
362
+ });
363
+
364
+ it('returns false and logs security audit when principal is not a global admin (ensuring non-admins are blocked and audited)', async () => {
365
+ globalRoleService.findOne.mockResolvedValue(null);
366
+
367
+ const result = await service.authorizeApiKeyAccessForPrincipal(
368
+ accountPrincipal,
369
+ trailmixModels.ApiKeyScope.Global
370
+ );
371
+
372
+ expect(result).toBe(false);
373
+ expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
374
+ event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
375
+ principal_id: principalId,
376
+ principal_type: trailmixModels.Principal.Account,
377
+ message: 'Unauthorized attempt to get global-scoped API key for non-global admins',
378
+ source: AuthorizationService.name,
379
+ });
380
+ });
381
+ });
382
+
383
+ describe('Account scope', () => {
384
+ it('returns true when scope ID matches principal ID (ensuring principals can access their own account-scoped API keys)', async () => {
385
+ globalRoleService.findOne.mockResolvedValue(null);
386
+ const scopeId = principalId;
387
+
388
+ const result = await service.authorizeApiKeyAccessForPrincipal(
389
+ accountPrincipal,
390
+ trailmixModels.ApiKeyScope.Account,
391
+ scopeId
392
+ );
393
+
394
+ expect(result).toBe(true);
395
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
396
+ });
397
+
398
+ it('returns true when principal is a global admin even if scope ID does not match (ensuring global admins can access any account-scoped API key)', async () => {
399
+ globalRoleService.findOne.mockResolvedValue(
400
+ TestUtils.Models.createGlobalRoleModel({
401
+ principal_id: principalId,
402
+ principal_type: trailmixModels.Principal.Account,
403
+ role: trailmixModels.RoleValue.Admin,
404
+ }),
405
+ );
406
+ const scopeId = new ObjectId();
407
+
408
+ const result = await service.authorizeApiKeyAccessForPrincipal(
409
+ accountPrincipal,
410
+ trailmixModels.ApiKeyScope.Account,
411
+ scopeId
412
+ );
413
+
414
+ expect(result).toBe(true);
415
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
416
+ });
417
+
418
+ it('throws Error when scope ID is missing (ensuring scope ID is required for account-scoped API keys)', async () => {
419
+ globalRoleService.findOne.mockResolvedValue(null);
420
+
421
+ await expect(
422
+ service.authorizeApiKeyAccessForPrincipal(
423
+ accountPrincipal,
424
+ trailmixModels.ApiKeyScope.Account
425
+ )
426
+ ).rejects.toThrow(Error);
427
+ });
428
+
429
+ it('returns false and logs security audit when scope ID does not match principal ID (ensuring principals cannot access other accounts\' API keys)', async () => {
430
+ globalRoleService.findOne.mockResolvedValue(null);
431
+ const scopeId = new ObjectId();
432
+
433
+ const result = await service.authorizeApiKeyAccessForPrincipal(
434
+ accountPrincipal,
435
+ trailmixModels.ApiKeyScope.Account,
436
+ scopeId
437
+ );
438
+
439
+ expect(result).toBe(false);
440
+ expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
441
+ event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
442
+ principal_id: principalId,
443
+ principal_type: trailmixModels.Principal.Account,
444
+ message: 'Unauthorized attempt to get account-scoped API key for another principal',
445
+ source: AuthorizationService.name,
446
+ });
447
+ });
448
+ });
449
+
450
+ describe('Organization scope', () => {
451
+ const organizationId = new ObjectId();
452
+
453
+ it('returns true when principal has Admin role on organization (ensuring Admin role grants access to organization-scoped API keys)', async () => {
454
+ globalRoleService.findOne.mockResolvedValue(null);
455
+ globalRoleService.find.mockResolvedValue([]);
456
+ const organizationRole = TestUtils.Models.createOrganizationRoleModel({
457
+ principal_id: principalId,
458
+ principal_type: trailmixModels.Principal.Account,
459
+ organization_id: organizationId,
460
+ role: trailmixModels.RoleValue.Admin,
461
+ });
462
+ organizationRoleService.find.mockResolvedValue([organizationRole]);
463
+
464
+ const result = await service.authorizeApiKeyAccessForPrincipal(
465
+ accountPrincipal,
466
+ trailmixModels.ApiKeyScope.Organization,
467
+ organizationId
468
+ );
469
+
470
+ expect(result).toBe(true);
471
+ expect(organizationRoleService.find).toHaveBeenCalledWith({
472
+ principal_id: principalId,
473
+ principal_type: trailmixModels.Principal.Account,
474
+ organization_id: organizationId,
475
+ });
476
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
477
+ });
478
+
479
+ it('returns true when principal has Owner role on organization (ensuring Owner role grants access to organization-scoped API keys)', async () => {
480
+ globalRoleService.findOne.mockResolvedValue(null);
481
+ globalRoleService.find.mockResolvedValue([]);
482
+ const organizationRole = TestUtils.Models.createOrganizationRoleModel({
483
+ principal_id: principalId,
484
+ principal_type: trailmixModels.Principal.Account,
485
+ organization_id: organizationId,
486
+ role: trailmixModels.RoleValue.Owner,
487
+ });
488
+ organizationRoleService.find.mockResolvedValue([organizationRole]);
489
+
490
+ const result = await service.authorizeApiKeyAccessForPrincipal(
491
+ accountPrincipal,
492
+ trailmixModels.ApiKeyScope.Organization,
493
+ organizationId
494
+ );
495
+
496
+ expect(result).toBe(true);
497
+ });
498
+
499
+ it('returns true when principal is a global admin (ensuring global admins can access any organization-scoped API key)', async () => {
500
+ globalRoleService.findOne.mockResolvedValue(
501
+ TestUtils.Models.createGlobalRoleModel({
502
+ principal_id: principalId,
503
+ principal_type: trailmixModels.Principal.Account,
504
+ role: trailmixModels.RoleValue.Admin,
505
+ }),
506
+ );
507
+ globalRoleService.find.mockResolvedValue([
508
+ TestUtils.Models.createGlobalRoleModel({
509
+ principal_id: principalId,
510
+ principal_type: trailmixModels.Principal.Account,
511
+ role: trailmixModels.RoleValue.Admin,
512
+ }),
513
+ ]);
514
+ organizationRoleService.find.mockResolvedValue([]);
515
+
516
+ const result = await service.authorizeApiKeyAccessForPrincipal(
517
+ accountPrincipal,
518
+ trailmixModels.ApiKeyScope.Organization,
519
+ organizationId
520
+ );
521
+
522
+ expect(result).toBe(true);
523
+ });
524
+
525
+ it('throws Error when scope ID is missing (ensuring scope ID is required for organization-scoped API keys)', async () => {
526
+ globalRoleService.findOne.mockResolvedValue(null);
527
+
528
+ await expect(
529
+ service.authorizeApiKeyAccessForPrincipal(
530
+ accountPrincipal,
531
+ trailmixModels.ApiKeyScope.Organization
532
+ )
533
+ ).rejects.toThrow(Error);
534
+ await expect(
535
+ service.authorizeApiKeyAccessForPrincipal(
536
+ accountPrincipal,
537
+ trailmixModels.ApiKeyScope.Organization
538
+ )
539
+ ).rejects.toThrow('API key scope ID is required for organization-scoped API keys');
540
+ });
541
+
542
+ it('returns false and logs security audit when principal does not have required role (ensuring insufficient roles are blocked and audited)', async () => {
543
+ globalRoleService.findOne.mockResolvedValue(null);
544
+ globalRoleService.find.mockResolvedValue([]);
545
+ organizationRoleService.find.mockResolvedValue([]);
546
+
547
+ const result = await service.authorizeApiKeyAccessForPrincipal(
548
+ accountPrincipal,
549
+ trailmixModels.ApiKeyScope.Organization,
550
+ organizationId
551
+ );
552
+
553
+ expect(result).toBe(false);
554
+ expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
555
+ event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
556
+ principal_id: principalId,
557
+ principal_type: trailmixModels.Principal.Account,
558
+ message: `Unauthorized attempt to get organization-scoped API key without ${[trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.Owner]} role on the organization ${organizationId}`,
559
+ source: AuthorizationService.name,
560
+ });
561
+ });
562
+
563
+ it('returns false and logs security audit when principal has insufficient role (ensuring User role does not grant access)', async () => {
564
+ globalRoleService.findOne.mockResolvedValue(null);
565
+ globalRoleService.find.mockResolvedValue([]);
566
+ const organizationRole = TestUtils.Models.createOrganizationRoleModel({
567
+ principal_id: principalId,
568
+ principal_type: trailmixModels.Principal.Account,
569
+ organization_id: organizationId,
570
+ role: trailmixModels.RoleValue.User,
571
+ });
572
+ organizationRoleService.find.mockResolvedValue([organizationRole]);
573
+
574
+ const result = await service.authorizeApiKeyAccessForPrincipal(
575
+ accountPrincipal,
576
+ trailmixModels.ApiKeyScope.Organization,
577
+ organizationId
578
+ );
579
+
580
+ expect(result).toBe(false);
581
+ expect(securityAuditCollection.insertOne).toHaveBeenCalled();
582
+ });
583
+
584
+ it('returns false for ApiKey principal type (ensuring ApiKey principals cannot access organization-scoped API keys)', async () => {
585
+ const apiKeyEntity = TestUtils.Entities.createApiKey();
586
+ const apiKeyPrincipal: RequestPrincipal = {
587
+ principal_type: trailmixModels.Principal.ApiKey,
588
+ entity: apiKeyEntity,
589
+ };
590
+ globalRoleService.findOne.mockResolvedValue(null);
591
+ globalRoleService.find.mockResolvedValue([]);
592
+ const organizationRole = TestUtils.Models.createOrganizationRoleModel({
593
+ principal_id: apiKeyEntity._id,
594
+ principal_type: trailmixModels.Principal.ApiKey,
595
+ organization_id: organizationId,
596
+ role: trailmixModels.RoleValue.Admin,
597
+ });
598
+ organizationRoleService.find.mockResolvedValue([organizationRole]);
599
+
600
+ const result = await service.authorizeApiKeyAccessForPrincipal(
601
+ apiKeyPrincipal,
602
+ trailmixModels.ApiKeyScope.Organization,
603
+ organizationId
604
+ );
605
+
606
+ expect(result).toBe(false);
607
+ expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
608
+ event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
609
+ principal_id: apiKeyEntity._id,
610
+ principal_type: trailmixModels.Principal.ApiKey,
611
+ message: `Unauthorized attempt to get organization-scoped API key without ${[trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.Owner]} role on the organization ${organizationId}`,
612
+ source: AuthorizationService.name,
613
+ });
614
+ });
615
+ });
616
+
617
+ describe('Invalid scope type', () => {
618
+ it('throws Error for invalid scope type (ensuring invalid scopes are rejected)', async () => {
619
+ globalRoleService.findOne.mockResolvedValue(null);
620
+
621
+ await expect(
622
+ service.authorizeApiKeyAccessForPrincipal(
623
+ accountPrincipal,
624
+ 'invalid_scope' as trailmixModels.ApiKeyScope
625
+ )
626
+ ).rejects.toThrow(Error);
627
+ await expect(
628
+ service.authorizeApiKeyAccessForPrincipal(
629
+ accountPrincipal,
630
+ 'invalid_scope' as trailmixModels.ApiKeyScope
631
+ )
632
+ ).rejects.toThrow('Invalid scope type: invalid_scope');
633
+ });
634
+ });
635
+ });
636
+ });
@@ -0,0 +1,56 @@
1
+ import { ApiKeyScope } from '@trailmix-cms/models';
2
+
3
+ import { FeatureService } from '@/services/feature.service';
4
+ import { FeatureConfig } from '@/types';
5
+
6
+ describe('FeatureService', () => {
7
+ let service: FeatureService;
8
+
9
+ describe('isOrganizationsEnabled', () => {
10
+ it('returns true when enableOrganizations is true', () => {
11
+ service = new FeatureService({ enableOrganizations: true });
12
+ expect(service.isOrganizationsEnabled()).toBe(true);
13
+ });
14
+
15
+ it('returns false when enableOrganizations is undefined', () => {
16
+ service = new FeatureService();
17
+ expect(service.isOrganizationsEnabled()).toBe(false);
18
+ });
19
+ });
20
+
21
+ describe('isApiKeysEnabled', () => {
22
+ it('returns true when apiKeys.enabled is true', () => {
23
+ service = new FeatureService({ apiKeys: { enabled: true } });
24
+ expect(service.isApiKeysEnabled()).toBe(true);
25
+ });
26
+
27
+ it('returns false when apiKeys is undefined', () => {
28
+ service = new FeatureService();
29
+ expect(service.isApiKeysEnabled()).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe('getApiKeyScopes', () => {
34
+ it('returns scopes when apiKeys.scopes is defined', () => {
35
+ const scopes = [ApiKeyScope.Account, ApiKeyScope.Organization];
36
+ service = new FeatureService({ apiKeys: { enabled: true, scopes } });
37
+ expect(service.getApiKeyScopes()).toEqual(scopes);
38
+ });
39
+
40
+ it('returns empty array when apiKeys.scopes is undefined', () => {
41
+ service = new FeatureService();
42
+ expect(service.getApiKeyScopes()).toEqual([]);
43
+ });
44
+ });
45
+
46
+ describe('getFeatures', () => {
47
+ it('returns copy of features config', () => {
48
+ const config: FeatureConfig = {
49
+ enableOrganizations: true,
50
+ apiKeys: { enabled: true, scopes: [ApiKeyScope.Account] },
51
+ };
52
+ service = new FeatureService(config);
53
+ expect(service.getFeatures()).toEqual(config);
54
+ });
55
+ });
56
+ });