@trailmix-cms/cms 0.4.4 → 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.
Files changed (267) 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 +24 -95
  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 +7 -6
  81. package/dist/decorators/auth.decorator.d.ts.map +1 -1
  82. package/dist/decorators/auth.decorator.js +38 -5
  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 +50 -0
  161. package/dist/services/auth.service.d.ts.map +1 -0
  162. package/dist/services/auth.service.js +259 -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/auth.guard.spec.ts +355 -0
  216. package/test/unit/collections/api-key.collection.spec.ts +416 -0
  217. package/test/unit/managers/global-role.manager.spec.ts +269 -0
  218. package/test/unit/managers/organization-role.manager.spec.ts +632 -0
  219. package/test/unit/managers/organization.manager.spec.ts +395 -0
  220. package/test/unit/module.spec.ts +596 -0
  221. package/test/unit/services/account.service.spec.ts +90 -0
  222. package/test/unit/services/api-key.service.spec.ts +1244 -0
  223. package/test/unit/services/auth.service.spec.ts +1036 -0
  224. package/test/unit/services/authorization.service.spec.ts +636 -0
  225. package/test/unit/services/feature.service.spec.ts +56 -0
  226. package/test/unit/services/global-role.service.spec.ts +289 -0
  227. package/test/unit/services/organization-role.service.spec.ts +300 -0
  228. package/test/unit/services/organization.service.spec.ts +385 -0
  229. package/test/utils/auth-guard.ts +114 -0
  230. package/test/utils/base.ts +16 -0
  231. package/test/utils/entities/account.ts +13 -0
  232. package/test/utils/entities/api-key.ts +15 -0
  233. package/test/utils/entities/audit.ts +18 -0
  234. package/test/utils/entities/index.ts +6 -0
  235. package/test/utils/entities/mapping.ts +20 -0
  236. package/test/utils/entities/organization.ts +13 -0
  237. package/test/utils/entities/role.ts +21 -0
  238. package/test/utils/entities/security-audit.ts +16 -0
  239. package/test/utils/index.ts +4 -0
  240. package/test/utils/models/audit-context.ts +10 -0
  241. package/test/utils/models/authorization.ts +7 -0
  242. package/test/utils/models/global-role.ts +22 -0
  243. package/test/utils/models/index.ts +5 -0
  244. package/test/utils/models/organization-role.ts +23 -0
  245. package/test/utils/models/publishable.ts +7 -0
  246. package/tsconfig.build.json +36 -0
  247. package/tsconfig.build.tsbuildinfo +1 -0
  248. package/dist/auth-guard-hook.d.ts.map +0 -1
  249. package/dist/auth-guard-hook.js.map +0 -1
  250. package/dist/cms.module.d.ts +0 -8
  251. package/dist/cms.module.d.ts.map +0 -1
  252. package/dist/cms.module.js +0 -44
  253. package/dist/cms.module.js.map +0 -1
  254. package/dist/cms.providers.d.ts +0 -120
  255. package/dist/cms.providers.d.ts.map +0 -1
  256. package/dist/cms.providers.js +0 -126
  257. package/dist/cms.providers.js.map +0 -1
  258. package/dist/collections/file.collection.d.ts +0 -21
  259. package/dist/collections/file.collection.d.ts.map +0 -1
  260. package/dist/collections/file.collection.js.map +0 -1
  261. package/dist/collections/text.collection.d.ts +0 -20
  262. package/dist/collections/text.collection.d.ts.map +0 -1
  263. package/dist/collections/text.collection.js +0 -56
  264. package/dist/collections/text.collection.js.map +0 -1
  265. package/dist/pipes/file.pipe.d.ts +0 -8
  266. /package/dist/{auth-guard-hook.d.ts → types/hooks/auth-guard-hook.d.ts} +0 -0
  267. /package/dist/{auth-guard-hook.js → types/hooks/auth-guard-hook.js} +0 -0
@@ -0,0 +1,1036 @@
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { Logger, InternalServerErrorException, ExecutionContext } from '@nestjs/common';
3
+ import { FastifyRequest } from 'fastify';
4
+ import { getAuth } from '@clerk/fastify';
5
+
6
+ import * as trailmixModels from '@trailmix-cms/models';
7
+
8
+ import { AuthService, AuthResult, GlobalRoleService } from '@/services';
9
+ import { AccountService } from '@/services/account.service';
10
+ import { AccountCollection, ApiKeyCollection, SecurityAuditCollection } from '@/collections';
11
+ import { PROVIDER_SYMBOLS } from '@/constants';
12
+ import { type RequestPrincipal } from '@/types';
13
+
14
+ import * as TestUtils from '../../utils';
15
+
16
+ // Mock @clerk/fastify
17
+ jest.mock('@clerk/fastify', () => ({
18
+ getAuth: jest.fn(),
19
+ }));
20
+
21
+ describe('AuthService', () => {
22
+ let service: AuthService;
23
+ let accountService: jest.Mocked<AccountService>;
24
+ let accountCollection: jest.Mocked<AccountCollection>;
25
+ let globalRoleService: jest.Mocked<GlobalRoleService>;
26
+ let securityAuditCollection: jest.Mocked<SecurityAuditCollection>;
27
+ let apiKeyCollection: jest.Mocked<ApiKeyCollection>;
28
+
29
+ beforeEach(async () => {
30
+ // Mock Logger methods to prevent console output during tests
31
+ jest.spyOn(Logger.prototype, 'log').mockImplementation();
32
+ jest.spyOn(Logger.prototype, 'error').mockImplementation();
33
+ jest.spyOn(Logger.prototype, 'warn').mockImplementation();
34
+ jest.spyOn(Logger.prototype, 'debug').mockImplementation();
35
+ jest.spyOn(Logger.prototype, 'verbose').mockImplementation();
36
+
37
+ const mockAccountService = {
38
+ getAccount: jest.fn(),
39
+ upsertAccount: jest.fn(),
40
+ };
41
+
42
+ const mockAccountCollection = {
43
+ findOne: jest.fn(),
44
+ };
45
+
46
+ const mockGlobalRoleService = {
47
+ find: jest.fn(),
48
+ };
49
+
50
+ const mockSecurityAuditCollection = {
51
+ insertOne: jest.fn().mockResolvedValue(undefined),
52
+ };
53
+
54
+ const mockApiKeyCollection = {
55
+ findOne: jest.fn(),
56
+ };
57
+
58
+ const module: TestingModule = await Test.createTestingModule({
59
+ providers: [
60
+ AuthService,
61
+ {
62
+ provide: AccountService,
63
+ useValue: mockAccountService,
64
+ },
65
+ {
66
+ provide: AccountCollection,
67
+ useValue: mockAccountCollection,
68
+ },
69
+ {
70
+ provide: GlobalRoleService,
71
+ useValue: mockGlobalRoleService,
72
+ },
73
+ {
74
+ provide: SecurityAuditCollection,
75
+ useValue: mockSecurityAuditCollection,
76
+ },
77
+ {
78
+ provide: ApiKeyCollection,
79
+ useValue: mockApiKeyCollection,
80
+ },
81
+ ],
82
+ }).compile();
83
+
84
+ service = module.get<AuthService>(AuthService);
85
+ accountService = module.get(AccountService);
86
+ accountCollection = module.get(AccountCollection);
87
+ globalRoleService = module.get(GlobalRoleService);
88
+ securityAuditCollection = module.get(SecurityAuditCollection);
89
+ apiKeyCollection = module.get(ApiKeyCollection);
90
+ });
91
+
92
+ afterEach(() => {
93
+ jest.clearAllMocks();
94
+ });
95
+
96
+ afterAll(() => {
97
+ jest.restoreAllMocks();
98
+ });
99
+
100
+ describe('validateAuth', () => {
101
+ const requestUrl = '/test/endpoint';
102
+ const accountEntity = TestUtils.Entities.createAccount();
103
+ const accountPrincipal: RequestPrincipal = {
104
+ entity: accountEntity,
105
+ principal_type: trailmixModels.Principal.Account,
106
+ };
107
+
108
+ describe('null principal', () => {
109
+ it('returns IsValid when allowAnonymous is true (ensuring anonymous access is allowed)', async () => {
110
+ const result = await service.validateAuth(
111
+ null,
112
+ {
113
+ allowAnonymous: true,
114
+ requiredPrincipalTypes: [],
115
+ requiredGlobalRoles: [],
116
+ requiredApiKeyScopes: [],
117
+ },
118
+ requestUrl
119
+ );
120
+
121
+ expect(result).toBe(AuthResult.IsValid);
122
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('returns Unauthorized when allowAnonymous is false (ensuring anonymous access is blocked)', async () => {
126
+ const result = await service.validateAuth(
127
+ null,
128
+ {
129
+ allowAnonymous: false,
130
+ requiredPrincipalTypes: [],
131
+ requiredGlobalRoles: [],
132
+ requiredApiKeyScopes: [],
133
+ },
134
+ requestUrl
135
+ );
136
+
137
+ expect(result).toBe(AuthResult.Unauthorized);
138
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
139
+ });
140
+ });
141
+
142
+ describe('principal type validation', () => {
143
+ it('returns IsValid when principal type matches required type (ensuring matching principal types pass)', async () => {
144
+ const result = await service.validateAuth(
145
+ accountPrincipal,
146
+ {
147
+ allowAnonymous: false,
148
+ requiredPrincipalTypes: [trailmixModels.Principal.Account],
149
+ requiredGlobalRoles: [],
150
+ requiredApiKeyScopes: [],
151
+ },
152
+ requestUrl
153
+ );
154
+
155
+ expect(result).toBe(AuthResult.IsValid);
156
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it('returns Forbidden when principal type does not match required type (ensuring non-matching principal types are rejected)', async () => {
160
+ const result = await service.validateAuth(
161
+ accountPrincipal,
162
+ {
163
+ allowAnonymous: false,
164
+ requiredPrincipalTypes: [trailmixModels.Principal.ApiKey],
165
+ requiredGlobalRoles: [],
166
+ requiredApiKeyScopes: [],
167
+ },
168
+ requestUrl
169
+ );
170
+
171
+ expect(result).toBe(AuthResult.Forbidden);
172
+ expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
173
+ event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
174
+ principal_id: accountEntity._id,
175
+ principal_type: trailmixModels.Principal.Account,
176
+ message: `Unauthorized access to ${requestUrl}, required principal type not found: ${trailmixModels.Principal.ApiKey}`,
177
+ source: AuthService.name,
178
+ });
179
+ });
180
+
181
+ it('returns IsValid when no principal types are required (ensuring any principal type passes when none required)', async () => {
182
+ const result = await service.validateAuth(
183
+ accountPrincipal,
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
+ },
286
+ requestUrl
287
+ );
288
+
289
+ expect(result).toBe(AuthResult.IsValid);
290
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
291
+ });
292
+ });
293
+
294
+ describe('role validation', () => {
295
+ it('returns IsValid when no roles are required (ensuring authenticated principals pass when no roles required)', async () => {
296
+ const result = await service.validateAuth(
297
+ accountPrincipal,
298
+ {
299
+ allowAnonymous: false,
300
+ requiredPrincipalTypes: [],
301
+ requiredGlobalRoles: [],
302
+ requiredApiKeyScopes: [],
303
+ },
304
+ requestUrl
305
+ );
306
+
307
+ expect(result).toBe(AuthResult.IsValid);
308
+ expect(globalRoleService.find).not.toHaveBeenCalled();
309
+ });
310
+
311
+ it('returns Forbidden when no global role assignments exist (ensuring null role assignments are rejected and audited)', async () => {
312
+ globalRoleService.find.mockResolvedValue(null as any);
313
+
314
+ const result = await service.validateAuth(
315
+ accountPrincipal,
316
+ {
317
+ allowAnonymous: false,
318
+ requiredPrincipalTypes: [],
319
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
320
+ requiredApiKeyScopes: [],
321
+ },
322
+ requestUrl
323
+ );
324
+
325
+ expect(result).toBe(AuthResult.Forbidden);
326
+ expect(globalRoleService.find).toHaveBeenCalledWith({
327
+ principal_id: accountEntity._id,
328
+ principal_type: trailmixModels.Principal.Account,
329
+ });
330
+ expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
331
+ event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
332
+ principal_id: accountEntity._id,
333
+ principal_type: trailmixModels.Principal.Account,
334
+ message: `Unauthorized access to ${requestUrl}, no global role assignments found`,
335
+ source: AuthService.name,
336
+ });
337
+ });
338
+
339
+ it('returns Forbidden when global role assignments is empty array (ensuring empty role assignments are rejected)', async () => {
340
+ globalRoleService.find.mockResolvedValue([]);
341
+
342
+ const result = await service.validateAuth(
343
+ accountPrincipal,
344
+ {
345
+ allowAnonymous: false,
346
+ requiredPrincipalTypes: [],
347
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
348
+ requiredApiKeyScopes: [],
349
+ },
350
+ requestUrl
351
+ );
352
+
353
+ expect(result).toBe(AuthResult.Forbidden);
354
+ expect(securityAuditCollection.insertOne).toHaveBeenCalled();
355
+ });
356
+
357
+ it('returns IsValid when principal has matching required role (ensuring matching roles pass)', async () => {
358
+ const userRole = TestUtils.Models.createGlobalRoleModel({
359
+ principal_id: accountEntity._id,
360
+ principal_type: trailmixModels.Principal.Account,
361
+ role: trailmixModels.RoleValue.User,
362
+ });
363
+ globalRoleService.find.mockResolvedValue([userRole]);
364
+
365
+ const result = await service.validateAuth(
366
+ accountPrincipal,
367
+ {
368
+ allowAnonymous: false,
369
+ requiredPrincipalTypes: [],
370
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
371
+ requiredApiKeyScopes: [],
372
+ },
373
+ requestUrl
374
+ );
375
+
376
+ expect(result).toBe(AuthResult.IsValid);
377
+ expect(globalRoleService.find).toHaveBeenCalledWith({
378
+ principal_id: accountEntity._id,
379
+ principal_type: trailmixModels.Principal.Account,
380
+ });
381
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
382
+ });
383
+
384
+ it('returns IsValid when principal has Admin role even if not in required roles (ensuring Admin role grants access)', async () => {
385
+ const adminRole = TestUtils.Models.createGlobalRoleModel({
386
+ principal_id: accountEntity._id,
387
+ principal_type: trailmixModels.Principal.Account,
388
+ role: trailmixModels.RoleValue.Admin,
389
+ });
390
+ globalRoleService.find.mockResolvedValue([adminRole]);
391
+
392
+ const result = await service.validateAuth(
393
+ accountPrincipal,
394
+ {
395
+ allowAnonymous: false,
396
+ requiredPrincipalTypes: [],
397
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
398
+ requiredApiKeyScopes: [],
399
+ },
400
+ requestUrl
401
+ );
402
+
403
+ expect(result).toBe(AuthResult.IsValid);
404
+ expect(securityAuditCollection.insertOne).not.toHaveBeenCalled();
405
+ });
406
+
407
+ it('returns IsValid when principal has one of multiple required roles (ensuring any matching role passes)', async () => {
408
+ const userRole = TestUtils.Models.createGlobalRoleModel({
409
+ principal_id: accountEntity._id,
410
+ principal_type: trailmixModels.Principal.Account,
411
+ role: trailmixModels.RoleValue.User,
412
+ });
413
+ globalRoleService.find.mockResolvedValue([userRole]);
414
+
415
+ const result = await service.validateAuth(
416
+ accountPrincipal,
417
+ {
418
+ allowAnonymous: false,
419
+ requiredPrincipalTypes: [],
420
+ requiredGlobalRoles: [trailmixModels.RoleValue.Admin, trailmixModels.RoleValue.User],
421
+ requiredApiKeyScopes: [],
422
+ },
423
+ requestUrl
424
+ );
425
+
426
+ expect(result).toBe(AuthResult.IsValid);
427
+ });
428
+
429
+ it('returns Forbidden when principal does not have required role (ensuring non-matching roles are rejected)', async () => {
430
+ const readerRole = TestUtils.Models.createGlobalRoleModel({
431
+ principal_id: accountEntity._id,
432
+ principal_type: trailmixModels.Principal.Account,
433
+ role: trailmixModels.RoleValue.Reader,
434
+ });
435
+ globalRoleService.find.mockResolvedValue([readerRole]);
436
+
437
+ const result = await service.validateAuth(
438
+ accountPrincipal,
439
+ {
440
+ allowAnonymous: false,
441
+ requiredPrincipalTypes: [],
442
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
443
+ requiredApiKeyScopes: [],
444
+ },
445
+ requestUrl
446
+ );
447
+
448
+ expect(result).toBe(AuthResult.Forbidden);
449
+ expect(securityAuditCollection.insertOne).toHaveBeenCalledWith({
450
+ event_type: trailmixModels.SecurityAuditEventType.UnauthorizedAccess,
451
+ principal_id: accountEntity._id,
452
+ principal_type: trailmixModels.Principal.Account,
453
+ message: `Unauthorized access to ${requestUrl}, required role not found: ${trailmixModels.RoleValue.User}`,
454
+ source: AuthService.name,
455
+ });
456
+ });
457
+
458
+ it('returns IsValid when principal has multiple roles including required one (ensuring role matching works with multiple roles)', async () => {
459
+ const roles = [
460
+ TestUtils.Models.createGlobalRoleModel({
461
+ principal_id: accountEntity._id,
462
+ principal_type: trailmixModels.Principal.Account,
463
+ role: trailmixModels.RoleValue.Reader,
464
+ }),
465
+ TestUtils.Models.createGlobalRoleModel({
466
+ principal_id: accountEntity._id,
467
+ principal_type: trailmixModels.Principal.Account,
468
+ role: trailmixModels.RoleValue.User,
469
+ }),
470
+ ];
471
+ globalRoleService.find.mockResolvedValue(roles);
472
+
473
+ const result = await service.validateAuth(
474
+ accountPrincipal,
475
+ {
476
+ allowAnonymous: false,
477
+ requiredPrincipalTypes: [],
478
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
479
+ requiredApiKeyScopes: [],
480
+ },
481
+ requestUrl
482
+ );
483
+
484
+ expect(result).toBe(AuthResult.IsValid);
485
+ });
486
+ });
487
+
488
+ describe('allowAnonymous with principal', () => {
489
+ it('returns IsValid when allowAnonymous is true even with principal (ensuring anonymous flag allows authenticated principals)', async () => {
490
+ const result = await service.validateAuth(
491
+ accountPrincipal,
492
+ {
493
+ allowAnonymous: true,
494
+ requiredPrincipalTypes: [],
495
+ requiredGlobalRoles: [trailmixModels.RoleValue.User],
496
+ requiredApiKeyScopes: [],
497
+ },
498
+ requestUrl
499
+ );
500
+
501
+ expect(result).toBe(AuthResult.IsValid);
502
+ expect(globalRoleService.find).not.toHaveBeenCalled();
503
+ });
504
+ });
505
+ });
506
+
507
+ describe('getPrincipal', () => {
508
+ const createMockContext = (request: Partial<FastifyRequest>): ExecutionContext => {
509
+ return {
510
+ switchToHttp: () => ({
511
+ getRequest: () => request as FastifyRequest,
512
+ }),
513
+ } as ExecutionContext;
514
+ };
515
+
516
+ describe('API key priority', () => {
517
+ it('returns API key principal when API key exists (ensuring API key takes priority over account)', async () => {
518
+ const apiKeyEntity = TestUtils.Entities.createApiKey();
519
+ const request = {
520
+ headers: {
521
+ [trailmixModels.API_KEY_HEADER]: apiKeyEntity.api_key,
522
+ },
523
+ };
524
+ const context = createMockContext(request);
525
+
526
+ apiKeyCollection.findOne.mockResolvedValue(apiKeyEntity);
527
+
528
+ const result = await service.getPrincipal(context);
529
+
530
+ expect(result).toEqual({
531
+ entity: apiKeyEntity,
532
+ principal_type: trailmixModels.Principal.ApiKey,
533
+ });
534
+ expect(apiKeyCollection.findOne).toHaveBeenCalledWith({ api_key: apiKeyEntity.api_key });
535
+ expect(accountService.getAccount).not.toHaveBeenCalled();
536
+ });
537
+ });
538
+
539
+ describe('account principal', () => {
540
+ it('returns account principal when account exists (ensuring existing accounts are returned)', async () => {
541
+ const accountEntity = TestUtils.Entities.createAccount();
542
+ const userId = 'user-123';
543
+ const request = {
544
+ headers: {},
545
+ };
546
+ const context = createMockContext(request);
547
+
548
+ (getAuth as jest.Mock).mockReturnValue({ userId });
549
+ accountService.getAccount.mockResolvedValue(accountEntity);
550
+ apiKeyCollection.findOne.mockResolvedValue(null);
551
+
552
+ const result = await service.getPrincipal(context);
553
+
554
+ expect(result).toEqual({
555
+ entity: accountEntity,
556
+ principal_type: trailmixModels.Principal.Account,
557
+ });
558
+ expect(accountService.getAccount).toHaveBeenCalledWith(userId);
559
+ expect(accountService.upsertAccount).not.toHaveBeenCalled();
560
+ });
561
+
562
+ it('returns account principal after creating new account (ensuring new accounts are created and returned)', async () => {
563
+ const accountEntity = TestUtils.Entities.createAccount();
564
+ const userId = 'user-123';
565
+ const request = {
566
+ headers: {},
567
+ };
568
+ const context = createMockContext(request);
569
+
570
+ (getAuth as jest.Mock).mockReturnValue({ userId });
571
+ accountService.getAccount.mockResolvedValue(null);
572
+ accountService.upsertAccount.mockResolvedValue(accountEntity);
573
+ apiKeyCollection.findOne.mockResolvedValue(null);
574
+
575
+ const result = await service.getPrincipal(context);
576
+
577
+ expect(result).toEqual({
578
+ entity: accountEntity,
579
+ principal_type: trailmixModels.Principal.Account,
580
+ });
581
+ expect(accountService.getAccount).toHaveBeenCalledWith(userId);
582
+ expect(accountService.upsertAccount).toHaveBeenCalledWith(userId);
583
+ });
584
+
585
+ it('does not call auth guard hook when hook is not provided (ensuring optional hook does not break flow)', async () => {
586
+ const accountEntity = TestUtils.Entities.createAccount();
587
+ const userId = 'user-123';
588
+ const request = {
589
+ headers: {},
590
+ };
591
+ const context = createMockContext(request);
592
+
593
+ (getAuth as jest.Mock).mockReturnValue({ userId });
594
+ accountService.getAccount.mockResolvedValue(null);
595
+ accountService.upsertAccount.mockResolvedValue(accountEntity);
596
+ apiKeyCollection.findOne.mockResolvedValue(null);
597
+
598
+ const result = await service.getPrincipal(context);
599
+
600
+ expect(result).not.toBeNull();
601
+ expect(result?.principal_type).toBe(trailmixModels.Principal.Account);
602
+ // Hook is not provided in default setup, so it should not be called
603
+ });
604
+
605
+ it('returns null when no userId and no API key (ensuring missing credentials return null)', async () => {
606
+ const request = {
607
+ headers: {},
608
+ };
609
+ const context = createMockContext(request);
610
+
611
+ (getAuth as jest.Mock).mockReturnValue({ userId: null });
612
+ apiKeyCollection.findOne.mockResolvedValue(null);
613
+
614
+ const result = await service.getPrincipal(context);
615
+
616
+ expect(result).toBeNull();
617
+ expect(accountService.getAccount).not.toHaveBeenCalled();
618
+ });
619
+ });
620
+ });
621
+
622
+ describe('getApiKey (via getPrincipal)', () => {
623
+ const createMockContext = (request: Partial<FastifyRequest>): ExecutionContext => {
624
+ return {
625
+ switchToHttp: () => ({
626
+ getRequest: () => request as FastifyRequest,
627
+ }),
628
+ } as ExecutionContext;
629
+ };
630
+
631
+ it('returns null when apiKeyCollection is not provided (ensuring missing collection returns null)', async () => {
632
+ const moduleWithoutApiKey: TestingModule = await Test.createTestingModule({
633
+ providers: [
634
+ AuthService,
635
+ {
636
+ provide: AccountService,
637
+ useValue: { getAccount: jest.fn(), upsertAccount: jest.fn() },
638
+ },
639
+ {
640
+ provide: AccountCollection,
641
+ useValue: { findOne: jest.fn() },
642
+ },
643
+ {
644
+ provide: GlobalRoleService,
645
+ useValue: { find: jest.fn() },
646
+ },
647
+ {
648
+ provide: SecurityAuditCollection,
649
+ useValue: { insertOne: jest.fn() },
650
+ },
651
+ ],
652
+ }).compile();
653
+
654
+ const serviceWithoutApiKey = moduleWithoutApiKey.get<AuthService>(AuthService);
655
+ const request = { headers: {} };
656
+ const context = createMockContext(request);
657
+
658
+ (getAuth as jest.Mock).mockReturnValue({ userId: null });
659
+
660
+ const result = await serviceWithoutApiKey.getPrincipal(context);
661
+
662
+ expect(result).toBeNull();
663
+ });
664
+
665
+ it('returns null when API key header is missing (ensuring missing header returns null)', async () => {
666
+ const request = {
667
+ headers: {},
668
+ };
669
+ const context = createMockContext(request);
670
+
671
+ (getAuth as jest.Mock).mockReturnValue({ userId: null });
672
+
673
+ const result = await service.getPrincipal(context);
674
+
675
+ expect(result).toBeNull();
676
+ expect(apiKeyCollection.findOne).not.toHaveBeenCalled();
677
+ });
678
+
679
+ it('returns null when API key not found (ensuring non-existent API keys return null)', async () => {
680
+ const apiKey = 'test-api-key';
681
+ const request = {
682
+ headers: {
683
+ [trailmixModels.API_KEY_HEADER]: apiKey,
684
+ },
685
+ };
686
+ const context = createMockContext(request);
687
+
688
+ apiKeyCollection.findOne.mockResolvedValue(null);
689
+ (getAuth as jest.Mock).mockReturnValue({ userId: null });
690
+
691
+ const result = await service.getPrincipal(context);
692
+
693
+ expect(result).toBeNull();
694
+ expect(apiKeyCollection.findOne).toHaveBeenCalledWith({ api_key: apiKey });
695
+ });
696
+
697
+ it('returns null when API key is disabled (ensuring disabled API keys return null)', async () => {
698
+ const apiKeyEntity = TestUtils.Entities.createApiKey({ disabled: true });
699
+ const request = {
700
+ headers: {
701
+ [trailmixModels.API_KEY_HEADER]: apiKeyEntity.api_key,
702
+ },
703
+ };
704
+ const context = createMockContext(request);
705
+
706
+ apiKeyCollection.findOne.mockResolvedValue(apiKeyEntity);
707
+ (getAuth as jest.Mock).mockReturnValue({ userId: null });
708
+
709
+ const result = await service.getPrincipal(context);
710
+
711
+ expect(result).toBeNull();
712
+ });
713
+
714
+ it('returns API key entity when valid (ensuring valid API keys are returned)', async () => {
715
+ const apiKeyEntity = TestUtils.Entities.createApiKey({ disabled: false });
716
+ const request = {
717
+ headers: {
718
+ [trailmixModels.API_KEY_HEADER]: apiKeyEntity.api_key,
719
+ },
720
+ };
721
+ const context = createMockContext(request);
722
+
723
+ apiKeyCollection.findOne.mockResolvedValue(apiKeyEntity);
724
+
725
+ const result = await service.getPrincipal(context);
726
+
727
+ expect(result).toEqual({
728
+ entity: apiKeyEntity,
729
+ principal_type: trailmixModels.Principal.ApiKey,
730
+ });
731
+ });
732
+ });
733
+
734
+ describe('auth guard hook', () => {
735
+ const createMockContext = (request: Partial<FastifyRequest>): ExecutionContext => {
736
+ return {
737
+ switchToHttp: () => ({
738
+ getRequest: () => request as FastifyRequest,
739
+ }),
740
+ } as ExecutionContext;
741
+ };
742
+
743
+ it('calls auth guard hook when creating new account (ensuring hook is called for new accounts)', async () => {
744
+ const mockAuthGuardHook = {
745
+ onHook: jest.fn().mockResolvedValue(true),
746
+ };
747
+
748
+ const moduleWithHook: TestingModule = await Test.createTestingModule({
749
+ providers: [
750
+ AuthService,
751
+ {
752
+ provide: AccountService,
753
+ useValue: {
754
+ getAccount: jest.fn().mockResolvedValue(null),
755
+ upsertAccount: jest.fn().mockResolvedValue(TestUtils.Entities.createAccount()),
756
+ },
757
+ },
758
+ {
759
+ provide: AccountCollection,
760
+ useValue: { findOne: jest.fn() },
761
+ },
762
+ {
763
+ provide: GlobalRoleService,
764
+ useValue: { find: jest.fn() },
765
+ },
766
+ {
767
+ provide: SecurityAuditCollection,
768
+ useValue: { insertOne: jest.fn() },
769
+ },
770
+ {
771
+ provide: ApiKeyCollection,
772
+ useValue: { findOne: jest.fn().mockResolvedValue(null) },
773
+ },
774
+ {
775
+ provide: PROVIDER_SYMBOLS.AUTH_GUARD_HOOK,
776
+ useValue: mockAuthGuardHook,
777
+ },
778
+ ],
779
+ }).compile();
780
+
781
+ const serviceWithHook = moduleWithHook.get<AuthService>(AuthService);
782
+ const accountEntity = TestUtils.Entities.createAccount();
783
+ const userId = 'user-123';
784
+ const request = { headers: {} };
785
+ const context = createMockContext(request);
786
+
787
+ (getAuth as jest.Mock).mockReturnValue({ userId });
788
+ const accountServiceWithHook = moduleWithHook.get<AccountService>(AccountService);
789
+ (accountServiceWithHook.getAccount as jest.Mock).mockResolvedValue(null);
790
+ (accountServiceWithHook.upsertAccount as jest.Mock).mockResolvedValue(accountEntity);
791
+
792
+ await serviceWithHook.getPrincipal(context);
793
+
794
+ expect(mockAuthGuardHook.onHook).toHaveBeenCalledWith(accountEntity);
795
+ expect(mockAuthGuardHook.onHook).toHaveBeenCalledTimes(1);
796
+ });
797
+
798
+ it('does not call auth guard hook when account already exists (ensuring hook is only called for new accounts)', async () => {
799
+ const mockAuthGuardHook = {
800
+ onHook: jest.fn().mockResolvedValue(true),
801
+ };
802
+
803
+ const moduleWithHook: TestingModule = await Test.createTestingModule({
804
+ providers: [
805
+ AuthService,
806
+ {
807
+ provide: AccountService,
808
+ useValue: {
809
+ getAccount: jest.fn().mockResolvedValue(TestUtils.Entities.createAccount()),
810
+ upsertAccount: jest.fn(),
811
+ },
812
+ },
813
+ {
814
+ provide: AccountCollection,
815
+ useValue: { findOne: jest.fn() },
816
+ },
817
+ {
818
+ provide: GlobalRoleService,
819
+ useValue: { find: jest.fn() },
820
+ },
821
+ {
822
+ provide: SecurityAuditCollection,
823
+ useValue: { insertOne: jest.fn() },
824
+ },
825
+ {
826
+ provide: ApiKeyCollection,
827
+ useValue: { findOne: jest.fn().mockResolvedValue(null) },
828
+ },
829
+ {
830
+ provide: PROVIDER_SYMBOLS.AUTH_GUARD_HOOK,
831
+ useValue: mockAuthGuardHook,
832
+ },
833
+ ],
834
+ }).compile();
835
+
836
+ const serviceWithHook = moduleWithHook.get<AuthService>(AuthService);
837
+ const userId = 'user-123';
838
+ const request = { headers: {} };
839
+ const context = createMockContext(request);
840
+
841
+ (getAuth as jest.Mock).mockReturnValue({ userId });
842
+
843
+ await serviceWithHook.getPrincipal(context);
844
+
845
+ expect(mockAuthGuardHook.onHook).not.toHaveBeenCalled();
846
+ });
847
+
848
+ it('throws InternalServerErrorException when auth guard hook returns false (ensuring hook rejection throws error)', async () => {
849
+ const mockAuthGuardHook = {
850
+ onHook: jest.fn().mockResolvedValue(false),
851
+ };
852
+
853
+ const moduleWithHook: TestingModule = await Test.createTestingModule({
854
+ providers: [
855
+ AuthService,
856
+ {
857
+ provide: AccountService,
858
+ useValue: {
859
+ getAccount: jest.fn().mockResolvedValue(null),
860
+ upsertAccount: jest.fn().mockResolvedValue(TestUtils.Entities.createAccount()),
861
+ },
862
+ },
863
+ {
864
+ provide: AccountCollection,
865
+ useValue: { findOne: jest.fn() },
866
+ },
867
+ {
868
+ provide: GlobalRoleService,
869
+ useValue: { find: jest.fn() },
870
+ },
871
+ {
872
+ provide: SecurityAuditCollection,
873
+ useValue: { insertOne: jest.fn() },
874
+ },
875
+ {
876
+ provide: ApiKeyCollection,
877
+ useValue: { findOne: jest.fn().mockResolvedValue(null) },
878
+ },
879
+ {
880
+ provide: PROVIDER_SYMBOLS.AUTH_GUARD_HOOK,
881
+ useValue: mockAuthGuardHook,
882
+ },
883
+ ],
884
+ }).compile();
885
+
886
+ const serviceWithHook = moduleWithHook.get<AuthService>(AuthService);
887
+ const accountEntity = TestUtils.Entities.createAccount();
888
+ const userId = 'user-123';
889
+ const request = { headers: {} };
890
+ const context = createMockContext(request);
891
+
892
+ (getAuth as jest.Mock).mockReturnValue({ userId });
893
+ const accountServiceWithHook = moduleWithHook.get<AccountService>(AccountService);
894
+ (accountServiceWithHook.getAccount as jest.Mock).mockResolvedValue(null);
895
+ (accountServiceWithHook.upsertAccount as jest.Mock).mockResolvedValue(accountEntity);
896
+
897
+ await expect(serviceWithHook.getPrincipal(context)).rejects.toThrow('Failed to validate account using auth guard hook');
898
+ expect(mockAuthGuardHook.onHook).toHaveBeenCalledWith(accountEntity);
899
+ });
900
+
901
+ it('allows account creation to proceed when auth guard hook returns true (ensuring hook approval allows flow)', async () => {
902
+ const mockAuthGuardHook = {
903
+ onHook: jest.fn().mockResolvedValue(true),
904
+ };
905
+
906
+ const moduleWithHook: TestingModule = await Test.createTestingModule({
907
+ providers: [
908
+ AuthService,
909
+ {
910
+ provide: AccountService,
911
+ useValue: {
912
+ getAccount: jest.fn().mockResolvedValue(null),
913
+ upsertAccount: jest.fn().mockResolvedValue(TestUtils.Entities.createAccount()),
914
+ },
915
+ },
916
+ {
917
+ provide: AccountCollection,
918
+ useValue: { findOne: jest.fn() },
919
+ },
920
+ {
921
+ provide: GlobalRoleService,
922
+ useValue: { find: jest.fn() },
923
+ },
924
+ {
925
+ provide: SecurityAuditCollection,
926
+ useValue: { insertOne: jest.fn() },
927
+ },
928
+ {
929
+ provide: ApiKeyCollection,
930
+ useValue: { findOne: jest.fn().mockResolvedValue(null) },
931
+ },
932
+ {
933
+ provide: PROVIDER_SYMBOLS.AUTH_GUARD_HOOK,
934
+ useValue: mockAuthGuardHook,
935
+ },
936
+ ],
937
+ }).compile();
938
+
939
+ const serviceWithHook = moduleWithHook.get<AuthService>(AuthService);
940
+ const accountEntity = TestUtils.Entities.createAccount();
941
+ const userId = 'user-123';
942
+ const request = { headers: {} };
943
+ const context = createMockContext(request);
944
+
945
+ (getAuth as jest.Mock).mockReturnValue({ userId });
946
+ const accountServiceWithHook = moduleWithHook.get<AccountService>(AccountService);
947
+ (accountServiceWithHook.getAccount as jest.Mock).mockResolvedValue(null);
948
+ (accountServiceWithHook.upsertAccount as jest.Mock).mockResolvedValue(accountEntity);
949
+
950
+ const result = await serviceWithHook.getPrincipal(context);
951
+
952
+ expect(result).not.toBeNull();
953
+ expect(result?.principal_type).toBe(trailmixModels.Principal.Account);
954
+ expect(result?.entity).toEqual(accountEntity);
955
+ expect(mockAuthGuardHook.onHook).toHaveBeenCalledWith(accountEntity);
956
+ });
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
+ });
1036
+ });