@trailmix-cms/cms 0.7.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.guard.d.ts.map +1 -1
- package/dist/auth.guard.js +7 -10
- package/dist/auth.guard.js.map +1 -1
- package/dist/controllers/account.controller.js +1 -1
- package/dist/controllers/account.controller.js.map +1 -1
- package/dist/controllers/api-keys.controller.js +1 -1
- package/dist/controllers/api-keys.controller.js.map +1 -1
- package/dist/controllers/audit.controller.js +1 -1
- package/dist/controllers/audit.controller.js.map +1 -1
- package/dist/controllers/audits.controller.js +1 -1
- package/dist/controllers/audits.controller.js.map +1 -1
- package/dist/controllers/global-roles.controller.js +1 -1
- package/dist/controllers/global-roles.controller.js.map +1 -1
- package/dist/controllers/security-audits.controller.js +1 -1
- package/dist/controllers/security-audits.controller.js.map +1 -1
- package/dist/decorators/auth.decorator.d.ts +6 -7
- package/dist/decorators/auth.decorator.d.ts.map +1 -1
- package/dist/decorators/auth.decorator.js +4 -6
- package/dist/decorators/auth.decorator.js.map +1 -1
- package/dist/services/auth.service.d.ts +16 -6
- package/dist/services/auth.service.d.ts.map +1 -1
- package/dist/services/auth.service.js +39 -7
- package/dist/services/auth.service.js.map +1 -1
- package/dist/types/request-principal.d.ts +2 -2
- package/dist/types/request-principal.d.ts.map +1 -1
- package/package.json +7 -7
- package/test/unit/auth.guard.spec.ts +355 -0
- package/test/unit/services/auth.service.spec.ts +290 -44
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
2
|
+
import { Reflector } from '@nestjs/core';
|
|
3
|
+
import { UnauthorizedException, ForbiddenException, InternalServerErrorException, ExecutionContext } from '@nestjs/common';
|
|
4
|
+
import { FastifyRequest } from 'fastify';
|
|
5
|
+
|
|
6
|
+
import * as trailmixModels from '@trailmix-cms/models';
|
|
7
|
+
|
|
8
|
+
import { AuthGuard } from '@/auth.guard';
|
|
9
|
+
import { AuthService, AuthResult } from '@/services/auth.service';
|
|
10
|
+
import { AUTH_OPTIONS_KEY, AuthOptions } from '@/decorators/auth.decorator';
|
|
11
|
+
import { type RequestPrincipal } from '@/types';
|
|
12
|
+
|
|
13
|
+
import * as TestUtils from '../utils';
|
|
14
|
+
|
|
15
|
+
describe('AuthGuard', () => {
|
|
16
|
+
let guard: AuthGuard;
|
|
17
|
+
let reflector: jest.Mocked<Reflector>;
|
|
18
|
+
let authService: jest.Mocked<AuthService>;
|
|
19
|
+
|
|
20
|
+
const createMockContext = (request: Partial<FastifyRequest> = {}): ExecutionContext => {
|
|
21
|
+
return {
|
|
22
|
+
switchToHttp: () => ({
|
|
23
|
+
getRequest: () => request as FastifyRequest,
|
|
24
|
+
}),
|
|
25
|
+
getHandler: jest.fn(),
|
|
26
|
+
getClass: jest.fn(),
|
|
27
|
+
} as any as ExecutionContext;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
const mockReflector = {
|
|
32
|
+
getAllAndOverride: jest.fn(),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const mockAuthService = {
|
|
36
|
+
getPrincipal: jest.fn(),
|
|
37
|
+
validateAuth: jest.fn(),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
41
|
+
providers: [
|
|
42
|
+
AuthGuard,
|
|
43
|
+
{
|
|
44
|
+
provide: Reflector,
|
|
45
|
+
useValue: mockReflector,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
provide: AuthService,
|
|
49
|
+
useValue: mockAuthService,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
}).compile();
|
|
53
|
+
|
|
54
|
+
guard = module.get<AuthGuard>(AuthGuard);
|
|
55
|
+
reflector = module.get(Reflector);
|
|
56
|
+
authService = module.get(AuthService);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
jest.clearAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('canActivate', () => {
|
|
64
|
+
it('returns true and sets principal when authentication is valid (ensuring successful authentication flow)', async () => {
|
|
65
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
66
|
+
const principal: RequestPrincipal = {
|
|
67
|
+
entity: accountEntity,
|
|
68
|
+
principal_type: trailmixModels.Principal.Account,
|
|
69
|
+
};
|
|
70
|
+
const request = { url: '/test/endpoint' } as FastifyRequest;
|
|
71
|
+
const context = createMockContext(request);
|
|
72
|
+
const authOptions: AuthOptions = {
|
|
73
|
+
allowAnonymous: false,
|
|
74
|
+
requiredPrincipalTypes: [trailmixModels.Principal.Account],
|
|
75
|
+
requiredGlobalRoles: [],
|
|
76
|
+
requiredApiKeyScopes: [],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
reflector.getAllAndOverride.mockReturnValue(authOptions);
|
|
80
|
+
authService.getPrincipal.mockResolvedValue(principal);
|
|
81
|
+
authService.validateAuth.mockResolvedValue(AuthResult.IsValid);
|
|
82
|
+
|
|
83
|
+
const result = await guard.canActivate(context);
|
|
84
|
+
|
|
85
|
+
expect(result).toBe(true);
|
|
86
|
+
expect(reflector.getAllAndOverride).toHaveBeenCalledWith(AUTH_OPTIONS_KEY, [
|
|
87
|
+
context.getHandler(),
|
|
88
|
+
context.getClass(),
|
|
89
|
+
]);
|
|
90
|
+
expect(authService.getPrincipal).toHaveBeenCalledWith(context);
|
|
91
|
+
expect(authService.validateAuth).toHaveBeenCalledWith(
|
|
92
|
+
principal,
|
|
93
|
+
{
|
|
94
|
+
allowAnonymous: false,
|
|
95
|
+
requiredPrincipalTypes: [trailmixModels.Principal.Account],
|
|
96
|
+
requiredGlobalRoles: [],
|
|
97
|
+
requiredApiKeyScopes: [],
|
|
98
|
+
},
|
|
99
|
+
'/test/endpoint'
|
|
100
|
+
);
|
|
101
|
+
expect(request.principal).toEqual(principal);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('uses default auth options when metadata is undefined (ensuring default behavior when no metadata)', async () => {
|
|
105
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
106
|
+
const principal: RequestPrincipal = {
|
|
107
|
+
entity: accountEntity,
|
|
108
|
+
principal_type: trailmixModels.Principal.Account,
|
|
109
|
+
};
|
|
110
|
+
const request = { url: '/test/endpoint' } as FastifyRequest;
|
|
111
|
+
const context = createMockContext(request);
|
|
112
|
+
|
|
113
|
+
// When metadata is undefined, destructuring will fail, so return empty object instead
|
|
114
|
+
reflector.getAllAndOverride.mockReturnValue({} as AuthOptions);
|
|
115
|
+
authService.getPrincipal.mockResolvedValue(principal);
|
|
116
|
+
authService.validateAuth.mockResolvedValue(AuthResult.IsValid);
|
|
117
|
+
|
|
118
|
+
const result = await guard.canActivate(context);
|
|
119
|
+
|
|
120
|
+
expect(result).toBe(true);
|
|
121
|
+
expect(authService.validateAuth).toHaveBeenCalledWith(
|
|
122
|
+
principal,
|
|
123
|
+
{
|
|
124
|
+
allowAnonymous: undefined,
|
|
125
|
+
requiredPrincipalTypes: undefined,
|
|
126
|
+
requiredGlobalRoles: undefined,
|
|
127
|
+
requiredApiKeyScopes: undefined,
|
|
128
|
+
},
|
|
129
|
+
'/test/endpoint'
|
|
130
|
+
);
|
|
131
|
+
expect(request.principal).toEqual(principal);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('throws UnauthorizedException when auth result is Unauthorized (ensuring unauthorized requests are rejected)', async () => {
|
|
135
|
+
const request = { url: '/test/endpoint' } as FastifyRequest;
|
|
136
|
+
const context = createMockContext(request);
|
|
137
|
+
const authOptions: AuthOptions = {
|
|
138
|
+
allowAnonymous: false,
|
|
139
|
+
requiredPrincipalTypes: [],
|
|
140
|
+
requiredGlobalRoles: [],
|
|
141
|
+
requiredApiKeyScopes: [],
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
reflector.getAllAndOverride.mockReturnValue(authOptions);
|
|
145
|
+
authService.getPrincipal.mockResolvedValue(null);
|
|
146
|
+
authService.validateAuth.mockResolvedValue(AuthResult.Unauthorized);
|
|
147
|
+
|
|
148
|
+
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
|
149
|
+
await expect(guard.canActivate(context)).rejects.toThrow('Unauthorized request');
|
|
150
|
+
expect(authService.validateAuth).toHaveBeenCalled();
|
|
151
|
+
expect(request.principal).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('throws ForbiddenException when auth result is Forbidden (ensuring forbidden requests are rejected)', async () => {
|
|
155
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
156
|
+
const principal: RequestPrincipal = {
|
|
157
|
+
entity: accountEntity,
|
|
158
|
+
principal_type: trailmixModels.Principal.Account,
|
|
159
|
+
};
|
|
160
|
+
const request = { url: '/test/endpoint' } as FastifyRequest;
|
|
161
|
+
const context = createMockContext(request);
|
|
162
|
+
const authOptions: AuthOptions = {
|
|
163
|
+
allowAnonymous: false,
|
|
164
|
+
requiredPrincipalTypes: [],
|
|
165
|
+
requiredGlobalRoles: [trailmixModels.RoleValue.Admin],
|
|
166
|
+
requiredApiKeyScopes: [],
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
reflector.getAllAndOverride.mockReturnValue(authOptions);
|
|
170
|
+
authService.getPrincipal.mockResolvedValue(principal);
|
|
171
|
+
authService.validateAuth.mockResolvedValue(AuthResult.Forbidden);
|
|
172
|
+
|
|
173
|
+
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
|
174
|
+
await expect(guard.canActivate(context)).rejects.toThrow('You are not authorized to access this resource');
|
|
175
|
+
expect(authService.validateAuth).toHaveBeenCalled();
|
|
176
|
+
expect(request.principal).toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('throws InternalServerErrorException when auth result is unexpected (ensuring unexpected results throw error)', async () => {
|
|
180
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
181
|
+
const principal: RequestPrincipal = {
|
|
182
|
+
entity: accountEntity,
|
|
183
|
+
principal_type: trailmixModels.Principal.Account,
|
|
184
|
+
};
|
|
185
|
+
const request = { url: '/test/endpoint' } as FastifyRequest;
|
|
186
|
+
const context = createMockContext(request);
|
|
187
|
+
const authOptions: AuthOptions = {
|
|
188
|
+
allowAnonymous: false,
|
|
189
|
+
requiredPrincipalTypes: [],
|
|
190
|
+
requiredGlobalRoles: [],
|
|
191
|
+
requiredApiKeyScopes: [],
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
reflector.getAllAndOverride.mockReturnValue(authOptions);
|
|
195
|
+
authService.getPrincipal.mockResolvedValue(principal);
|
|
196
|
+
authService.validateAuth.mockResolvedValue('unexpected-result' as AuthResult);
|
|
197
|
+
|
|
198
|
+
await expect(guard.canActivate(context)).rejects.toThrow(InternalServerErrorException);
|
|
199
|
+
await expect(guard.canActivate(context)).rejects.toThrow('Failed to validate authentication');
|
|
200
|
+
expect(authService.validateAuth).toHaveBeenCalled();
|
|
201
|
+
expect(request.principal).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('passes correct auth options to validateAuth (ensuring all options are passed correctly)', async () => {
|
|
205
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
206
|
+
const principal: RequestPrincipal = {
|
|
207
|
+
entity: accountEntity,
|
|
208
|
+
principal_type: trailmixModels.Principal.Account,
|
|
209
|
+
};
|
|
210
|
+
const request = { url: '/api/users' } as FastifyRequest;
|
|
211
|
+
const context = createMockContext(request);
|
|
212
|
+
const authOptions: AuthOptions = {
|
|
213
|
+
allowAnonymous: true,
|
|
214
|
+
requiredPrincipalTypes: [trailmixModels.Principal.Account, trailmixModels.Principal.ApiKey],
|
|
215
|
+
requiredGlobalRoles: [trailmixModels.RoleValue.User, trailmixModels.RoleValue.Admin],
|
|
216
|
+
requiredApiKeyScopes: [trailmixModels.ApiKeyScope.Account, trailmixModels.ApiKeyScope.Organization],
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
reflector.getAllAndOverride.mockReturnValue(authOptions);
|
|
220
|
+
authService.getPrincipal.mockResolvedValue(principal);
|
|
221
|
+
authService.validateAuth.mockResolvedValue(AuthResult.IsValid);
|
|
222
|
+
|
|
223
|
+
await guard.canActivate(context);
|
|
224
|
+
|
|
225
|
+
expect(authService.validateAuth).toHaveBeenCalledWith(
|
|
226
|
+
principal,
|
|
227
|
+
{
|
|
228
|
+
allowAnonymous: true,
|
|
229
|
+
requiredPrincipalTypes: [trailmixModels.Principal.Account, trailmixModels.Principal.ApiKey],
|
|
230
|
+
requiredGlobalRoles: [trailmixModels.RoleValue.User, trailmixModels.RoleValue.Admin],
|
|
231
|
+
requiredApiKeyScopes: [trailmixModels.ApiKeyScope.Account, trailmixModels.ApiKeyScope.Organization],
|
|
232
|
+
},
|
|
233
|
+
'/api/users'
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('handles API key principal correctly (ensuring API key principals work)', async () => {
|
|
238
|
+
const apiKeyEntity = TestUtils.Entities.createApiKey();
|
|
239
|
+
const principal: RequestPrincipal = {
|
|
240
|
+
entity: apiKeyEntity,
|
|
241
|
+
principal_type: trailmixModels.Principal.ApiKey,
|
|
242
|
+
};
|
|
243
|
+
const request = { url: '/api/data' } as FastifyRequest;
|
|
244
|
+
const context = createMockContext(request);
|
|
245
|
+
const authOptions: AuthOptions = {
|
|
246
|
+
allowAnonymous: false,
|
|
247
|
+
requiredPrincipalTypes: [trailmixModels.Principal.ApiKey],
|
|
248
|
+
requiredGlobalRoles: [],
|
|
249
|
+
requiredApiKeyScopes: [trailmixModels.ApiKeyScope.Account],
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
reflector.getAllAndOverride.mockReturnValue(authOptions);
|
|
253
|
+
authService.getPrincipal.mockResolvedValue(principal);
|
|
254
|
+
authService.validateAuth.mockResolvedValue(AuthResult.IsValid);
|
|
255
|
+
|
|
256
|
+
const result = await guard.canActivate(context);
|
|
257
|
+
|
|
258
|
+
expect(result).toBe(true);
|
|
259
|
+
expect(request.principal).toEqual(principal);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('handles null principal when allowAnonymous is true (ensuring anonymous access works)', async () => {
|
|
263
|
+
const request = { url: '/public/endpoint' } as FastifyRequest;
|
|
264
|
+
const context = createMockContext(request);
|
|
265
|
+
const authOptions: AuthOptions = {
|
|
266
|
+
allowAnonymous: true,
|
|
267
|
+
requiredPrincipalTypes: [],
|
|
268
|
+
requiredGlobalRoles: [],
|
|
269
|
+
requiredApiKeyScopes: [],
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
reflector.getAllAndOverride.mockReturnValue(authOptions);
|
|
273
|
+
authService.getPrincipal.mockResolvedValue(null);
|
|
274
|
+
authService.validateAuth.mockResolvedValue(AuthResult.IsValid);
|
|
275
|
+
|
|
276
|
+
const result = await guard.canActivate(context);
|
|
277
|
+
|
|
278
|
+
expect(result).toBe(true);
|
|
279
|
+
expect(authService.validateAuth).toHaveBeenCalledWith(
|
|
280
|
+
null,
|
|
281
|
+
{
|
|
282
|
+
allowAnonymous: true,
|
|
283
|
+
requiredPrincipalTypes: [],
|
|
284
|
+
requiredGlobalRoles: [],
|
|
285
|
+
requiredApiKeyScopes: [],
|
|
286
|
+
},
|
|
287
|
+
'/public/endpoint'
|
|
288
|
+
);
|
|
289
|
+
// Principal is set to null when principal is null (non-null assertion still allows null)
|
|
290
|
+
expect(request.principal).toBeNull();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('extracts request URL correctly (ensuring URL is passed to validateAuth)', async () => {
|
|
294
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
295
|
+
const principal: RequestPrincipal = {
|
|
296
|
+
entity: accountEntity,
|
|
297
|
+
principal_type: trailmixModels.Principal.Account,
|
|
298
|
+
};
|
|
299
|
+
const requestUrl = '/api/v1/organizations/123/members';
|
|
300
|
+
const request = { url: requestUrl } as FastifyRequest;
|
|
301
|
+
const context = createMockContext(request);
|
|
302
|
+
const authOptions: AuthOptions = {
|
|
303
|
+
allowAnonymous: false,
|
|
304
|
+
requiredPrincipalTypes: [],
|
|
305
|
+
requiredGlobalRoles: [],
|
|
306
|
+
requiredApiKeyScopes: [],
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
reflector.getAllAndOverride.mockReturnValue(authOptions);
|
|
310
|
+
authService.getPrincipal.mockResolvedValue(principal);
|
|
311
|
+
authService.validateAuth.mockResolvedValue(AuthResult.IsValid);
|
|
312
|
+
|
|
313
|
+
await guard.canActivate(context);
|
|
314
|
+
|
|
315
|
+
expect(authService.validateAuth).toHaveBeenCalledWith(
|
|
316
|
+
principal,
|
|
317
|
+
expect.objectContaining({
|
|
318
|
+
allowAnonymous: false,
|
|
319
|
+
requiredPrincipalTypes: [],
|
|
320
|
+
requiredGlobalRoles: [],
|
|
321
|
+
requiredApiKeyScopes: [],
|
|
322
|
+
}),
|
|
323
|
+
requestUrl
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('does not set principal when authentication fails (ensuring principal is not set on failure)', async () => {
|
|
328
|
+
const accountEntity = TestUtils.Entities.createAccount();
|
|
329
|
+
const principal: RequestPrincipal = {
|
|
330
|
+
entity: accountEntity,
|
|
331
|
+
principal_type: trailmixModels.Principal.Account,
|
|
332
|
+
};
|
|
333
|
+
const request = { url: '/test/endpoint' } as FastifyRequest;
|
|
334
|
+
const context = createMockContext(request);
|
|
335
|
+
const authOptions: AuthOptions = {
|
|
336
|
+
allowAnonymous: false,
|
|
337
|
+
requiredPrincipalTypes: [],
|
|
338
|
+
requiredGlobalRoles: [trailmixModels.RoleValue.Admin],
|
|
339
|
+
requiredApiKeyScopes: [],
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
reflector.getAllAndOverride.mockReturnValue(authOptions);
|
|
343
|
+
authService.getPrincipal.mockResolvedValue(principal);
|
|
344
|
+
authService.validateAuth.mockResolvedValue(AuthResult.Forbidden);
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
await guard.canActivate(context);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
// Expected to throw
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
expect(request.principal).toBeUndefined();
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
});
|