@nauth-toolkit/core 0.1.0 → 0.1.3

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 (184) hide show
  1. package/LICENSE +90 -0
  2. package/README.md +30 -0
  3. package/package.json +7 -2
  4. package/jest.config.js +0 -15
  5. package/jest.setup.ts +0 -6
  6. package/src/adapters/database-columns.ts +0 -165
  7. package/src/adapters/express.adapter.ts +0 -385
  8. package/src/adapters/fastify.adapter.ts +0 -416
  9. package/src/adapters/index.ts +0 -16
  10. package/src/adapters/storage.factory.ts +0 -143
  11. package/src/bootstrap.ts +0 -374
  12. package/src/dto/auth-challenge.dto.ts +0 -231
  13. package/src/dto/auth-response.dto.ts +0 -253
  14. package/src/dto/challenge-response.dto.ts +0 -234
  15. package/src/dto/change-password-request.dto.ts +0 -50
  16. package/src/dto/change-password-response.dto.ts +0 -29
  17. package/src/dto/change-password.dto.ts +0 -57
  18. package/src/dto/error-response.dto.ts +0 -136
  19. package/src/dto/get-available-methods.dto.ts +0 -55
  20. package/src/dto/get-challenge-data-response.dto.ts +0 -28
  21. package/src/dto/get-challenge-data.dto.ts +0 -69
  22. package/src/dto/get-client-info.dto.ts +0 -104
  23. package/src/dto/get-device-token-response.dto.ts +0 -25
  24. package/src/dto/get-events-by-type.dto.ts +0 -76
  25. package/src/dto/get-ip-address-response.dto.ts +0 -24
  26. package/src/dto/get-mfa-status.dto.ts +0 -94
  27. package/src/dto/get-risk-assessment-history.dto.ts +0 -39
  28. package/src/dto/get-session-id-response.dto.ts +0 -25
  29. package/src/dto/get-setup-data-response.dto.ts +0 -31
  30. package/src/dto/get-setup-data.dto.ts +0 -75
  31. package/src/dto/get-suspicious-activity.dto.ts +0 -42
  32. package/src/dto/get-user-agent-response.dto.ts +0 -23
  33. package/src/dto/get-user-auth-history.dto.ts +0 -95
  34. package/src/dto/get-user-by-email.dto.ts +0 -61
  35. package/src/dto/get-user-by-id.dto.ts +0 -46
  36. package/src/dto/get-user-devices.dto.ts +0 -53
  37. package/src/dto/get-user-response.dto.ts +0 -17
  38. package/src/dto/has-provider.dto.ts +0 -56
  39. package/src/dto/index.ts +0 -57
  40. package/src/dto/is-trusted-device-response.dto.ts +0 -34
  41. package/src/dto/list-providers-response.dto.ts +0 -23
  42. package/src/dto/login.dto.ts +0 -95
  43. package/src/dto/logout-all-response.dto.ts +0 -24
  44. package/src/dto/logout-all.dto.ts +0 -65
  45. package/src/dto/logout-response.dto.ts +0 -25
  46. package/src/dto/logout.dto.ts +0 -64
  47. package/src/dto/refresh-token.dto.ts +0 -36
  48. package/src/dto/remove-devices.dto.ts +0 -85
  49. package/src/dto/resend-code-response.dto.ts +0 -32
  50. package/src/dto/resend-code.dto.ts +0 -51
  51. package/src/dto/reset-password.dto.ts +0 -115
  52. package/src/dto/respond-challenge.dto.ts +0 -272
  53. package/src/dto/set-mfa-exemption.dto.ts +0 -112
  54. package/src/dto/set-must-change-password-response.dto.ts +0 -27
  55. package/src/dto/set-must-change-password.dto.ts +0 -46
  56. package/src/dto/set-preferred-method.dto.ts +0 -80
  57. package/src/dto/setup-mfa.dto.ts +0 -98
  58. package/src/dto/signup.dto.ts +0 -174
  59. package/src/dto/social-auth.dto.ts +0 -422
  60. package/src/dto/trust-device-response.dto.ts +0 -30
  61. package/src/dto/trust-device.dto.ts +0 -9
  62. package/src/dto/update-user-attributes-request.dto.ts +0 -51
  63. package/src/dto/user-response.dto.ts +0 -138
  64. package/src/dto/user-update.dto.ts +0 -222
  65. package/src/dto/verify-email.dto.ts +0 -313
  66. package/src/dto/verify-mfa-code.dto.ts +0 -103
  67. package/src/dto/verify-phone-by-sub.dto.ts +0 -78
  68. package/src/dto/verify-phone.dto.ts +0 -245
  69. package/src/entities/auth-audit.entity.ts +0 -232
  70. package/src/entities/challenge-session.entity.ts +0 -116
  71. package/src/entities/index.ts +0 -29
  72. package/src/entities/login-attempt.entity.ts +0 -64
  73. package/src/entities/mfa-device.entity.ts +0 -151
  74. package/src/entities/rate-limit.entity.ts +0 -44
  75. package/src/entities/session.entity.ts +0 -180
  76. package/src/entities/social-account.entity.ts +0 -96
  77. package/src/entities/storage-lock.entity.ts +0 -39
  78. package/src/entities/trusted-device.entity.ts +0 -112
  79. package/src/entities/user.entity.ts +0 -243
  80. package/src/entities/verification-token.entity.ts +0 -141
  81. package/src/enums/auth-audit-event-type.enum.ts +0 -360
  82. package/src/enums/error-codes.enum.ts +0 -420
  83. package/src/enums/mfa-method.enum.ts +0 -97
  84. package/src/enums/risk-factor.enum.ts +0 -111
  85. package/src/exceptions/nauth.exception.ts +0 -231
  86. package/src/handlers/auth.handler.ts +0 -260
  87. package/src/handlers/client-info.handler.ts +0 -101
  88. package/src/handlers/csrf.handler.ts +0 -156
  89. package/src/handlers/token-delivery.handler.ts +0 -118
  90. package/src/index.ts +0 -118
  91. package/src/interfaces/client-info.interface.ts +0 -85
  92. package/src/interfaces/config.interface.ts +0 -2135
  93. package/src/interfaces/entities.interface.ts +0 -226
  94. package/src/interfaces/index.ts +0 -15
  95. package/src/interfaces/logger.interface.ts +0 -283
  96. package/src/interfaces/mfa-provider.interface.ts +0 -154
  97. package/src/interfaces/oauth.interface.ts +0 -148
  98. package/src/interfaces/provider.interface.ts +0 -47
  99. package/src/interfaces/social-auth-provider.interface.ts +0 -131
  100. package/src/interfaces/storage-adapter.interface.ts +0 -82
  101. package/src/interfaces/template.interface.ts +0 -510
  102. package/src/interfaces/token-verifier.interface.ts +0 -110
  103. package/src/internal.ts +0 -178
  104. package/src/platform/interfaces.ts +0 -299
  105. package/src/schemas/auth-config.schema.ts +0 -646
  106. package/src/services/adaptive-mfa-decision.service.spec.ts +0 -1058
  107. package/src/services/adaptive-mfa-decision.service.ts +0 -457
  108. package/src/services/auth-audit.service.spec.ts +0 -675
  109. package/src/services/auth-audit.service.ts +0 -558
  110. package/src/services/auth-challenge-helper.service.spec.ts +0 -3227
  111. package/src/services/auth-challenge-helper.service.ts +0 -825
  112. package/src/services/auth-flow-context-builder.service.ts +0 -520
  113. package/src/services/auth-flow-rules.ts +0 -202
  114. package/src/services/auth-flow-state-definitions.ts +0 -190
  115. package/src/services/auth-flow-state-machine.service.ts +0 -207
  116. package/src/services/auth-flow-state-machine.types.ts +0 -316
  117. package/src/services/auth.service.spec.ts +0 -4195
  118. package/src/services/auth.service.ts +0 -3727
  119. package/src/services/challenge.service.spec.ts +0 -1363
  120. package/src/services/challenge.service.ts +0 -696
  121. package/src/services/client-info.service.spec.ts +0 -572
  122. package/src/services/client-info.service.ts +0 -374
  123. package/src/services/csrf.service.ts +0 -54
  124. package/src/services/email-verification.service.spec.ts +0 -1229
  125. package/src/services/email-verification.service.ts +0 -578
  126. package/src/services/geo-location.service.spec.ts +0 -603
  127. package/src/services/geo-location.service.ts +0 -599
  128. package/src/services/index.ts +0 -13
  129. package/src/services/jwt.service.spec.ts +0 -882
  130. package/src/services/jwt.service.ts +0 -621
  131. package/src/services/mfa-base.service.spec.ts +0 -246
  132. package/src/services/mfa-base.service.ts +0 -611
  133. package/src/services/mfa.service.spec.ts +0 -693
  134. package/src/services/mfa.service.ts +0 -960
  135. package/src/services/password.service.spec.ts +0 -166
  136. package/src/services/password.service.ts +0 -309
  137. package/src/services/phone-verification.service.spec.ts +0 -1120
  138. package/src/services/phone-verification.service.ts +0 -751
  139. package/src/services/risk-detection.service.spec.ts +0 -1292
  140. package/src/services/risk-detection.service.ts +0 -1012
  141. package/src/services/risk-scoring.service.spec.ts +0 -204
  142. package/src/services/risk-scoring.service.ts +0 -131
  143. package/src/services/session.service.spec.ts +0 -1293
  144. package/src/services/session.service.ts +0 -803
  145. package/src/services/social-account.service.spec.ts +0 -725
  146. package/src/services/social-auth-base.service.spec.ts +0 -418
  147. package/src/services/social-auth-base.service.ts +0 -581
  148. package/src/services/social-auth.service.spec.ts +0 -238
  149. package/src/services/social-auth.service.ts +0 -436
  150. package/src/services/social-provider-registry.service.spec.ts +0 -238
  151. package/src/services/social-provider-registry.service.ts +0 -122
  152. package/src/services/trusted-device.service.spec.ts +0 -505
  153. package/src/services/trusted-device.service.ts +0 -339
  154. package/src/storage/account-lockout-storage.service.spec.ts +0 -310
  155. package/src/storage/account-lockout-storage.service.ts +0 -89
  156. package/src/storage/index.ts +0 -3
  157. package/src/storage/memory-storage.adapter.ts +0 -443
  158. package/src/storage/rate-limit-storage.service.spec.ts +0 -247
  159. package/src/storage/rate-limit-storage.service.ts +0 -38
  160. package/src/templates/html-template.engine.spec.ts +0 -161
  161. package/src/templates/html-template.engine.ts +0 -688
  162. package/src/templates/index.ts +0 -7
  163. package/src/utils/common-passwords.spec.ts +0 -230
  164. package/src/utils/common-passwords.ts +0 -170
  165. package/src/utils/context-storage.ts +0 -188
  166. package/src/utils/cookie-names.util.ts +0 -67
  167. package/src/utils/cookies.util.ts +0 -94
  168. package/src/utils/index.ts +0 -12
  169. package/src/utils/ip-extractor.spec.ts +0 -330
  170. package/src/utils/ip-extractor.ts +0 -220
  171. package/src/utils/nauth-logger.spec.ts +0 -388
  172. package/src/utils/nauth-logger.ts +0 -215
  173. package/src/utils/pii-redactor.spec.ts +0 -130
  174. package/src/utils/pii-redactor.ts +0 -288
  175. package/src/utils/setup/get-repositories.ts +0 -140
  176. package/src/utils/setup/init-services.ts +0 -422
  177. package/src/utils/setup/init-social.ts +0 -189
  178. package/src/utils/setup/init-storage.ts +0 -94
  179. package/src/utils/setup/register-mfa.ts +0 -165
  180. package/src/utils/setup/run-nauth-migrations.ts +0 -61
  181. package/src/utils/token-delivery-policy.ts +0 -38
  182. package/src/validators/template.validator.ts +0 -219
  183. package/tsconfig.json +0 -37
  184. package/tsconfig.lint.json +0 -6
@@ -1,4195 +0,0 @@
1
- /**
2
- * AuthService Unit Tests - Comprehensive Coverage
3
- *
4
- * Platform-agnostic: Uses direct instantiation, no NestJS dependencies.
5
- *
6
- * This is the backbone test suite for the authentication system.
7
- * Covers all public methods, edge cases, security features, hooks, and error paths.
8
- *
9
- * Test Coverage:
10
- * - User signup (all verification methods, hooks, edge cases)
11
- * - User login (all scenarios, lockout, MFA, challenges, hooks)
12
- * - Token refresh (rotation, reuse detection, distributed locking)
13
- * - Logout operations (single, all, token family)
14
- * - Password management (change, reset, history, expiry)
15
- * - Account lockout (IP-based, account-based, unlock)
16
- * - MFA verification (all methods)
17
- * - Challenge completion (all challenge types)
18
- * - Trusted device management
19
- * - User profile updates
20
- * - Lifecycle hooks (all hooks)
21
- * - Optional dependencies handling
22
- * - Audit logging
23
- * - Security features (constant-time, token rotation, reuse detection)
24
- */
25
-
26
- import { Repository } from 'typeorm';
27
- import { AuthService } from './auth.service';
28
- import { NAuthException } from '../exceptions/nauth.exception';
29
- import { PasswordService } from './password.service';
30
- import { JwtService } from './jwt.service';
31
- import { SessionService } from './session.service';
32
- import { EmailVerificationService } from './email-verification.service';
33
- import { PhoneVerificationService } from './phone-verification.service';
34
- import { ClientInfoService } from './client-info.service';
35
- import { AccountLockoutStorageService } from '../storage/account-lockout-storage.service';
36
- import { ChallengeService } from './challenge.service';
37
- import { AuthChallengeHelperService } from './auth-challenge-helper.service';
38
- import { AuthAuditService } from './auth-audit.service';
39
- import { TrustedDeviceService } from './trusted-device.service';
40
- import { MFAService } from './mfa.service';
41
- import { SignupDTO } from '../dto/signup.dto';
42
- import { LoginDTO } from '../dto/login.dto';
43
- import { AuthChallenge } from '../dto/auth-challenge.dto';
44
- import { IUser, ISession } from '../interfaces/entities.interface';
45
- import { BaseUser, BaseLoginAttempt, BaseMFADevice } from '../entities';
46
- import { NAuthConfig } from '../interfaces/config.interface';
47
- import { NAuthLogger } from '../utils/nauth-logger';
48
- import { AuthErrorCode } from '../enums/error-codes.enum';
49
- import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
50
- import { RiskFactor } from '../enums/risk-factor.enum';
51
- import {
52
- VerifyEmailResponse,
53
- VerifyPhoneResponse,
54
- CollectPhoneResponse,
55
- ForceChangePasswordResponse,
56
- MFASetupResponse,
57
- VerifyMFACodeResponse,
58
- VerifyMFAPasskeyResponse,
59
- } from '../dto/challenge-response.dto';
60
- import { RespondChallengeDTO } from '../dto/respond-challenge.dto';
61
- import { MFAMethod } from '../enums/mfa-method.enum';
62
-
63
- const createRespondChallengeDto = (data: Partial<RespondChallengeDTO>): RespondChallengeDTO => {
64
- return Object.assign(new RespondChallengeDTO(), data);
65
- };
66
-
67
- describe('AuthService', () => {
68
- let service: AuthService;
69
- let mockUserRepository: jest.Mocked<Repository<BaseUser>>;
70
- let mockLoginAttemptRepository: jest.Mocked<Repository<BaseLoginAttempt>>;
71
- let mockMfaDeviceRepository: jest.Mocked<Repository<BaseMFADevice>>;
72
- let mockPasswordService: jest.Mocked<PasswordService>;
73
- let mockJwtService: jest.Mocked<JwtService>;
74
- let mockSessionService: jest.Mocked<SessionService>;
75
- let mockEmailVerificationService: jest.Mocked<EmailVerificationService>;
76
- let mockPhoneVerificationService: jest.Mocked<PhoneVerificationService>;
77
- let mockClientInfoService: jest.Mocked<ClientInfoService>;
78
- let mockAccountLockoutStorage: jest.Mocked<AccountLockoutStorageService>;
79
- let mockChallengeService: jest.Mocked<ChallengeService>;
80
- let mockChallengeHelper: jest.Mocked<AuthChallengeHelperService>;
81
- let mockAuditService: jest.Mocked<AuthAuditService>;
82
- let mockTrustedDeviceService: jest.Mocked<TrustedDeviceService>;
83
- let mockMfaService: jest.Mocked<MFAService>;
84
- let mockLogger: jest.Mocked<NAuthLogger>;
85
- let mockConfig: NAuthConfig;
86
-
87
- const mockUser: IUser = {
88
- id: 1,
89
- sub: 'user-123',
90
- email: 'test@example.com',
91
- username: 'testuser',
92
- phone: null,
93
- firstName: 'John',
94
- lastName: 'Doe',
95
- passwordHash: 'hashed-password',
96
- passwordChangedAt: new Date(),
97
- passwordHistory: [],
98
- isEmailVerified: true,
99
- isPhoneVerified: false,
100
- isActive: true,
101
- mustChangePassword: false,
102
- isLocked: false,
103
- lockReason: null,
104
- lockedAt: null,
105
- lockedUntil: null,
106
- failedLoginAttempts: 0,
107
- lastFailedLoginAt: null,
108
- lastLoginAt: null,
109
- lastLoginIp: null,
110
- hasSocialAuth: false,
111
- socialProviders: null,
112
- mfaEnabled: false,
113
- mfaMethods: null,
114
- preferredMfaMethod: null,
115
- backupCodes: null,
116
- metadata: null,
117
- createdAt: new Date(),
118
- updatedAt: new Date(),
119
- deletedAt: null,
120
- };
121
-
122
- const mockSession: ISession = {
123
- id: 1,
124
- userId: 1,
125
- accessTokenHash: 'access-hash',
126
- refreshTokenHash: 'refresh-hash',
127
- tokenFamily: 'family-abc',
128
- deviceId: 'device-123',
129
- deviceName: 'Test Device',
130
- deviceType: 'desktop',
131
- deviceFingerprint: null,
132
- ipAddress: '127.0.0.1',
133
- ipCountry: null,
134
- ipCity: null,
135
- ipIsp: null,
136
- userAgent: 'test-agent',
137
- platform: 'web',
138
- browser: 'chrome',
139
- authMethod: 'password',
140
- isRemembered: false,
141
- isTrustedDevice: false,
142
- expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
143
- lastActivityAt: new Date(),
144
- isRevoked: false,
145
- revokedAt: null,
146
- revokeReason: null,
147
- version: 1,
148
- metadata: null,
149
- createdAt: new Date(),
150
- } as ISession;
151
-
152
- const mockClientInfo: any = {
153
- ipAddress: '127.0.0.1',
154
- ipCountry: 'US',
155
- ipCity: 'San Francisco',
156
- deviceToken: null,
157
- deviceName: 'Test Device',
158
- deviceType: 'desktop',
159
- userAgent: 'test-agent',
160
- platform: 'web',
161
- browser: 'chrome',
162
- };
163
-
164
- beforeEach(() => {
165
- // Create mock repositories
166
- // Create a fresh query builder mock for each test
167
- const createMockQueryBuilder = () => ({
168
- where: jest.fn().mockReturnThis(),
169
- orWhere: jest.fn().mockReturnThis(),
170
- select: jest.fn().mockReturnThis(),
171
- getOne: jest.fn(),
172
- });
173
- mockUserRepository = {
174
- findOne: jest.fn(),
175
- save: jest.fn(),
176
- create: jest.fn(),
177
- update: jest.fn(),
178
- createQueryBuilder: jest.fn(createMockQueryBuilder),
179
- } as any;
180
-
181
- mockLoginAttemptRepository = {
182
- create: jest.fn(),
183
- save: jest.fn(),
184
- } as any;
185
-
186
- mockMfaDeviceRepository = {
187
- find: jest.fn(),
188
- update: jest.fn(),
189
- } as any;
190
-
191
- // Create mock services
192
- mockPasswordService = {
193
- validatePassword: jest.fn(),
194
- hashPassword: jest.fn(),
195
- verifyPassword: jest.fn(),
196
- isPasswordInHistory: jest.fn(),
197
- addToHistory: jest.fn(),
198
- } as any;
199
-
200
- mockJwtService = {
201
- generateTokenPair: jest.fn(),
202
- hashToken: jest.fn(),
203
- generateTokenFamily: jest.fn(),
204
- validateAccessToken: jest.fn(),
205
- validateRefreshToken: jest.fn(),
206
- decodeToken: jest.fn(),
207
- getRefreshTokenTTL: jest.fn().mockReturnValue(2592000), // 30 days
208
- } as any;
209
-
210
- mockSessionService = {
211
- createSession: jest.fn(),
212
- createSessionAtomic: jest.fn(),
213
- findByRefreshToken: jest.fn(),
214
- findByIdLight: jest.fn(),
215
- updateTokens: jest.fn(),
216
- revokeSession: jest.fn(),
217
- revokeAllUserSessions: jest.fn(),
218
- markRefreshTokenAsUsed: jest.fn(),
219
- isRefreshTokenUsed: jest.fn().mockResolvedValue(false),
220
- acquireRefreshLock: jest.fn().mockResolvedValue(true),
221
- releaseRefreshLock: jest.fn(),
222
- revokeTokenFamily: jest.fn(),
223
- findById: jest.fn(),
224
- } as any;
225
-
226
- mockEmailVerificationService = {
227
- sendVerificationEmail: jest.fn(),
228
- } as any;
229
-
230
- mockPhoneVerificationService = {
231
- sendVerificationSMS: jest.fn(),
232
- sendVerificationCode: jest.fn(),
233
- verifyPhoneWithCode: jest.fn(),
234
- } as any;
235
-
236
- mockClientInfoService = {
237
- get: jest.fn().mockReturnValue(mockClientInfo),
238
- } as any;
239
-
240
- mockAccountLockoutStorage = {
241
- isAccountLocked: jest.fn().mockResolvedValue(false),
242
- recordFailedAttempt: jest.fn().mockResolvedValue(1),
243
- resetFailedAttempts: jest.fn().mockResolvedValue(undefined),
244
- lockAccount: jest.fn().mockResolvedValue(undefined),
245
- } as any;
246
-
247
- mockChallengeService = {
248
- createSession: jest.fn(),
249
- createChallengeSession: jest.fn(),
250
- validateSession: jest.fn(),
251
- validateAndConsumeSession: jest.fn(),
252
- incrementAttempts: jest.fn(),
253
- cleanupExpiredSessions: jest.fn(),
254
- } as any;
255
-
256
- mockChallengeHelper = {
257
- determineAuthResponse: jest.fn(),
258
- createChallengeResponse: jest.fn(),
259
- createSuccessResponse: jest.fn(),
260
- createMFAChallengeResponse: jest.fn(),
261
- createMFASetupChallengeResponse: jest.fn(),
262
- } as any;
263
-
264
- mockAuditService = {
265
- recordEvent: jest.fn().mockResolvedValue(null),
266
- } as any;
267
-
268
- mockTrustedDeviceService = {
269
- isDeviceTrusted: jest.fn().mockResolvedValue(false),
270
- createTrustedDevice: jest.fn().mockResolvedValue('device-token-123'),
271
- revokeTrustedDevice: jest.fn().mockResolvedValue(undefined),
272
- } as any;
273
-
274
- mockMfaService = {
275
- verifyCode: jest.fn().mockResolvedValue(true),
276
- } as any;
277
-
278
- mockLogger = {
279
- log: jest.fn(),
280
- debug: jest.fn(),
281
- warn: jest.fn(),
282
- error: jest.fn(),
283
- verbose: jest.fn(),
284
- } as any;
285
-
286
- // Default config
287
- mockConfig = {
288
- jwt: {
289
- algorithm: 'HS256',
290
- accessToken: {
291
- secret: 'test-secret',
292
- expiresIn: 900, // 15 minutes
293
- },
294
- refreshToken: {
295
- secret: 'test-refresh-secret',
296
- expiresIn: 2592000, // 30 days
297
- rotation: true,
298
- reuseDetection: true,
299
- },
300
- },
301
- signup: {
302
- enabled: true,
303
- verificationMethod: 'none',
304
- },
305
- login: {},
306
- lockout: {
307
- enabled: true,
308
- maxAttempts: 5,
309
- duration: 900,
310
- resetOnSuccess: true,
311
- },
312
- password: {
313
- historyCount: 5,
314
- },
315
- session: {},
316
- };
317
-
318
- // Instantiate service directly
319
- service = new AuthService(
320
- mockUserRepository,
321
- mockLoginAttemptRepository,
322
- mockPasswordService,
323
- mockJwtService,
324
- mockSessionService,
325
- mockChallengeService,
326
- mockChallengeHelper,
327
- mockEmailVerificationService,
328
- mockClientInfoService,
329
- mockAccountLockoutStorage,
330
- mockConfig,
331
- mockLogger,
332
- mockAuditService,
333
- mockPhoneVerificationService,
334
- mockMfaService,
335
- mockMfaDeviceRepository,
336
- mockTrustedDeviceService,
337
- );
338
- });
339
-
340
- afterEach(() => {
341
- jest.clearAllMocks();
342
- });
343
-
344
- // ============================================================================
345
- // Service Initialization
346
- // ============================================================================
347
-
348
- it('should be defined', () => {
349
- expect(service).toBeDefined();
350
- });
351
-
352
- it('should log initialization', () => {
353
- expect(mockLogger.log).toHaveBeenCalledWith('AuthService initialized');
354
- });
355
-
356
- // ============================================================================
357
- // signup Tests
358
- // ============================================================================
359
-
360
- describe('signup()', () => {
361
- const signupDto: SignupDTO = {
362
- email: 'newuser@example.com',
363
- password: 'SecurePassword123!',
364
- username: 'newuser',
365
- };
366
-
367
- beforeEach(() => {
368
- mockChallengeHelper.determineAuthResponse.mockResolvedValue({
369
- accessToken: 'access-token',
370
- refreshToken: 'refresh-token',
371
- accessTokenExpiresAt: Math.floor(Date.now() / 1000) + 900,
372
- refreshTokenExpiresAt: Math.floor(Date.now() / 1000) + 604800,
373
- user: {
374
- sub: 'user-123',
375
- email: 'newuser@example.com',
376
- isEmailVerified: true,
377
- isPhoneVerified: false,
378
- },
379
- });
380
- });
381
-
382
- describe('Basic signup flow', () => {
383
- it('should create a new user successfully with verificationMethod: none', async () => {
384
- mockUserRepository.findOne.mockResolvedValue(null);
385
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
386
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
387
- mockUserRepository.create.mockReturnValue(mockUser as any);
388
- mockUserRepository.save.mockResolvedValue(mockUser as any);
389
-
390
- const result = await service.signup(signupDto);
391
-
392
- expect(mockUserRepository.findOne).toHaveBeenCalledWith({ where: { email: signupDto.email } });
393
- expect(mockPasswordService.validatePassword).toHaveBeenCalled();
394
- expect(mockPasswordService.hashPassword).toHaveBeenCalledWith(signupDto.password);
395
- expect(mockUserRepository.save).toHaveBeenCalled();
396
- expect(mockChallengeHelper.determineAuthResponse).toHaveBeenCalled();
397
- expect(result.user).toBeDefined();
398
- expect(result.accessToken).toBe('access-token');
399
- expect(result.refreshToken).toBe('refresh-token');
400
- expect(result.challengeName).toBeUndefined();
401
- });
402
-
403
- it('should hash password with Argon2id', async () => {
404
- mockUserRepository.findOne.mockResolvedValue(null);
405
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
406
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
407
- mockUserRepository.create.mockReturnValue(mockUser as any);
408
- mockUserRepository.save.mockResolvedValue(mockUser as any);
409
-
410
- await service.signup(signupDto);
411
-
412
- expect(mockPasswordService.hashPassword).toHaveBeenCalledWith(signupDto.password);
413
- });
414
-
415
- it('should create user with isActive: true and isEmailVerified always false initially', async () => {
416
- mockUserRepository.findOne.mockResolvedValue(null);
417
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
418
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
419
- const createdUser = { ...mockUser, email: signupDto.email };
420
- mockUserRepository.create.mockReturnValue(createdUser as any);
421
- mockUserRepository.save.mockResolvedValue(createdUser as any);
422
-
423
- await service.signup(signupDto);
424
-
425
- expect(mockUserRepository.create).toHaveBeenCalledWith(
426
- (expect as any).objectContaining({
427
- email: signupDto.email,
428
- passwordHash: 'hashed-password',
429
- isActive: true,
430
- isEmailVerified: false, // Always false initially - must be explicitly verified
431
- }),
432
- );
433
- });
434
- });
435
-
436
- describe('Duplicate checks', () => {
437
- it('should throw NAuthException if user with email already exists', async () => {
438
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
439
-
440
- try {
441
- await service.signup(signupDto);
442
- fail('Should have thrown NAuthException');
443
- } catch (error: any) {
444
- expect(error).toBeInstanceOf(NAuthException);
445
- expect(error.code).toBe(AuthErrorCode.EMAIL_EXISTS);
446
- }
447
- });
448
-
449
- it('should throw NAuthException if username already exists', async () => {
450
- mockUserRepository.findOne.mockImplementation((options: any) => {
451
- if (options.where?.email) return Promise.resolve(null);
452
- if (options.where?.username) return Promise.resolve(mockUser as any);
453
- return Promise.resolve(null);
454
- });
455
-
456
- try {
457
- await service.signup(signupDto);
458
- fail('Should have thrown NAuthException');
459
- } catch (error: any) {
460
- expect(error).toBeInstanceOf(NAuthException);
461
- expect(error.code).toBe(AuthErrorCode.USERNAME_EXISTS);
462
- }
463
- });
464
-
465
- it('should throw NAuthException if phone already exists when allowDuplicatePhones is false', async () => {
466
- const signupDtoWithPhone: SignupDTO = {
467
- ...signupDto,
468
- phone: '+1234567890',
469
- };
470
- mockConfig.signup!.allowDuplicatePhones = false;
471
-
472
- mockUserRepository.findOne.mockImplementation((options: any) => {
473
- if (options.where?.email) return Promise.resolve(null);
474
- if (options.where?.username) return Promise.resolve(null);
475
- if (options.where?.phone) return Promise.resolve(mockUser as any);
476
- return Promise.resolve(null);
477
- });
478
-
479
- try {
480
- await service.signup(signupDtoWithPhone);
481
- fail('Should have thrown NAuthException');
482
- } catch (error: any) {
483
- expect(error).toBeInstanceOf(NAuthException);
484
- expect(error.code).toBe(AuthErrorCode.PHONE_EXISTS);
485
- }
486
- });
487
-
488
- it('should allow duplicate phones when allowDuplicatePhones is true', async () => {
489
- const signupDtoWithPhone: SignupDTO = {
490
- ...signupDto,
491
- phone: '+1234567890',
492
- };
493
- mockConfig.signup!.allowDuplicatePhones = true;
494
-
495
- mockUserRepository.findOne.mockImplementation((options: any) => {
496
- if (options.where?.email) return Promise.resolve(null);
497
- if (options.where?.username) return Promise.resolve(null);
498
- // Don't check phone when allowDuplicatePhones is true
499
- return Promise.resolve(null);
500
- });
501
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
502
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
503
- mockUserRepository.create.mockReturnValue(mockUser as any);
504
- mockUserRepository.save.mockResolvedValue(mockUser as any);
505
-
506
- await service.signup(signupDtoWithPhone);
507
-
508
- // Should not throw
509
- expect(mockUserRepository.save).toHaveBeenCalled();
510
- });
511
- });
512
-
513
- describe('Password validation', () => {
514
- it('should throw NAuthException if password is invalid', async () => {
515
- mockUserRepository.findOne.mockResolvedValue(null);
516
- mockPasswordService.validatePassword.mockResolvedValue({
517
- valid: false,
518
- errors: ['Password is too weak', 'Password must contain uppercase'],
519
- });
520
-
521
- try {
522
- await service.signup(signupDto);
523
- fail('Should have thrown NAuthException');
524
- } catch (error: any) {
525
- expect(error).toBeInstanceOf(NAuthException);
526
- // Password validation happens in handleForceChangePassword, which throws WEAK_PASSWORD
527
- // But validation might happen earlier in validateChallengeParams
528
- expect([AuthErrorCode.WEAK_PASSWORD, AuthErrorCode.VALIDATION_FAILED]).toContain(error.code);
529
- expect(error.message).toContain('Password is too weak');
530
- }
531
- });
532
-
533
- it('should pass email and username to password validation', async () => {
534
- mockUserRepository.findOne.mockResolvedValue(null);
535
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
536
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
537
- mockUserRepository.create.mockReturnValue(mockUser as any);
538
- mockUserRepository.save.mockResolvedValue(mockUser as any);
539
-
540
- await service.signup(signupDto);
541
-
542
- expect(mockPasswordService.validatePassword).toHaveBeenCalledWith(signupDto.password, {
543
- email: signupDto.email,
544
- username: signupDto.username,
545
- });
546
- });
547
- });
548
-
549
- describe('Verification method: email', () => {
550
- beforeEach(() => {
551
- mockConfig.signup!.verificationMethod = 'email';
552
- mockChallengeHelper.determineAuthResponse.mockResolvedValue({
553
- challengeName: AuthChallenge.VERIFY_EMAIL,
554
- session: 'session-token-123',
555
- challengeParameters: {
556
- email: signupDto.email,
557
- instructions: 'Please verify your email address',
558
- },
559
- });
560
- });
561
-
562
- it('should return VERIFY_EMAIL challenge', async () => {
563
- mockUserRepository.findOne.mockResolvedValue(null);
564
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
565
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
566
- mockUserRepository.create.mockReturnValue(mockUser as any);
567
- mockUserRepository.save.mockResolvedValue(mockUser as any);
568
- mockEmailVerificationService.sendVerificationEmail.mockResolvedValue(123);
569
-
570
- const result = await service.signup(signupDto);
571
-
572
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
573
- expect(result.session).toBe('session-token-123');
574
- expect(result.accessToken).toBeUndefined();
575
- expect(result.refreshToken).toBeUndefined();
576
- expect(mockEmailVerificationService.sendVerificationEmail).toHaveBeenCalled();
577
- });
578
-
579
- it('should create user with isEmailVerified: false', async () => {
580
- mockUserRepository.findOne.mockResolvedValue(null);
581
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
582
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
583
- const createdUser = { ...mockUser, email: signupDto.email, isEmailVerified: false };
584
- mockUserRepository.create.mockReturnValue(createdUser as any);
585
- mockUserRepository.save.mockResolvedValue(createdUser as any);
586
- mockEmailVerificationService.sendVerificationEmail.mockResolvedValue(123);
587
-
588
- await service.signup(signupDto);
589
-
590
- expect(mockUserRepository.create).toHaveBeenCalledWith(
591
- (expect as any).objectContaining({
592
- isEmailVerified: false,
593
- }),
594
- );
595
- });
596
- });
597
-
598
- describe('Verification method: phone', () => {
599
- beforeEach(() => {
600
- mockConfig.signup!.verificationMethod = 'phone';
601
- mockChallengeHelper.determineAuthResponse.mockResolvedValue({
602
- challengeName: AuthChallenge.VERIFY_PHONE,
603
- session: 'session-token-123',
604
- challengeParameters: {
605
- phone: '+1234567890',
606
- instructions: 'Please verify your phone number',
607
- },
608
- });
609
- });
610
-
611
- it('should throw NAuthException if phone is required but not provided', async () => {
612
- mockUserRepository.findOne.mockResolvedValue(null);
613
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
614
-
615
- try {
616
- await service.signup(signupDto);
617
- fail('Should have thrown NAuthException');
618
- } catch (error: any) {
619
- expect(error).toBeInstanceOf(NAuthException);
620
- expect(error.code).toBe(AuthErrorCode.PHONE_REQUIRED);
621
- }
622
- });
623
-
624
- it('should return VERIFY_PHONE challenge when phone is provided', async () => {
625
- const signupDtoWithPhone: SignupDTO = {
626
- ...signupDto,
627
- phone: '+1234567890',
628
- };
629
-
630
- mockUserRepository.findOne.mockResolvedValue(null);
631
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
632
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
633
- mockUserRepository.create.mockReturnValue(mockUser as any);
634
- mockUserRepository.save.mockResolvedValue(mockUser as any);
635
-
636
- const result = await service.signup(signupDtoWithPhone);
637
-
638
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_PHONE);
639
- // Phone verification SMS is sent during challenge completion, not during signup
640
- });
641
- });
642
-
643
- describe('Verification method: both', () => {
644
- beforeEach(() => {
645
- mockConfig.signup!.verificationMethod = 'both';
646
- // Sequential challenges: first VERIFY_EMAIL, then VERIFY_PHONE
647
- mockChallengeHelper.determineAuthResponse.mockResolvedValue({
648
- challengeName: AuthChallenge.VERIFY_EMAIL,
649
- session: 'session-token-email',
650
- challengeParameters: {
651
- email: signupDto.email,
652
- codeDeliveryDestination: 't***@example.com',
653
- },
654
- });
655
- });
656
-
657
- it('should throw NAuthException if phone is required but not provided', async () => {
658
- mockUserRepository.findOne.mockResolvedValue(null);
659
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
660
-
661
- try {
662
- await service.signup(signupDto);
663
- fail('Should have thrown NAuthException');
664
- } catch (error: any) {
665
- expect(error).toBeInstanceOf(NAuthException);
666
- expect(error.code).toBe(AuthErrorCode.PHONE_REQUIRED);
667
- }
668
- });
669
-
670
- it('should return VERIFY_EMAIL challenge first when both are provided (sequential flow)', async () => {
671
- const signupDtoWithPhone: SignupDTO = {
672
- ...signupDto,
673
- phone: '+1234567890',
674
- };
675
-
676
- mockUserRepository.findOne.mockResolvedValue(null);
677
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
678
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
679
- mockUserRepository.create.mockReturnValue(mockUser as any);
680
- mockUserRepository.save.mockResolvedValue(mockUser as any);
681
- mockEmailVerificationService.sendVerificationEmail.mockResolvedValue(123);
682
-
683
- const result = await service.signup(signupDtoWithPhone);
684
-
685
- // Sequential challenges: first VERIFY_EMAIL, then VERIFY_PHONE after email is verified
686
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
687
- // When verificationMethod is 'both', email is sent by challenge system when VERIFY_EMAIL challenge is created
688
- // Phone verification SMS is sent when VERIFY_PHONE challenge is created (after email is verified)
689
- });
690
- });
691
-
692
- describe('Lifecycle hooks', () => {
693
- it('should execute beforeSignup hook and reject if returns false', async () => {
694
- mockConfig.hooks = {
695
- beforeSignup: jest.fn().mockResolvedValue(false),
696
- };
697
- mockUserRepository.findOne.mockResolvedValue(null);
698
-
699
- try {
700
- await service.signup(signupDto);
701
- fail('Should have thrown NAuthException');
702
- } catch (error: any) {
703
- expect(error).toBeInstanceOf(NAuthException);
704
- expect(error.code).toBe(AuthErrorCode.SIGNUP_NOT_ALLOWED);
705
- expect(mockConfig.hooks!.beforeSignup).toHaveBeenCalledWith(signupDto);
706
- }
707
- });
708
-
709
- it('should allow signup if beforeSignup hook returns true', async () => {
710
- mockConfig.hooks = {
711
- beforeSignup: jest.fn().mockResolvedValue(true),
712
- };
713
- mockUserRepository.findOne.mockResolvedValue(null);
714
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
715
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
716
- mockUserRepository.create.mockReturnValue(mockUser as any);
717
- mockUserRepository.save.mockResolvedValue(mockUser as any);
718
-
719
- await service.signup(signupDto);
720
-
721
- expect(mockConfig.hooks!.beforeSignup).toHaveBeenCalled();
722
- expect(mockUserRepository.save).toHaveBeenCalled();
723
- });
724
-
725
- it('should execute afterSignup hook after successful signup', async () => {
726
- mockConfig.hooks = {
727
- afterSignup: jest.fn().mockResolvedValue(undefined),
728
- };
729
- mockUserRepository.findOne.mockResolvedValue(null);
730
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
731
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
732
- mockUserRepository.create.mockReturnValue(mockUser as any);
733
- mockUserRepository.save.mockResolvedValue(mockUser as any);
734
-
735
- await service.signup(signupDto);
736
-
737
- expect(mockConfig.hooks!.afterSignup).toHaveBeenCalled();
738
- });
739
- });
740
-
741
- describe('Signup disabled', () => {
742
- it('should throw NAuthException if signup is disabled', async () => {
743
- mockConfig.signup!.enabled = false;
744
-
745
- try {
746
- await service.signup(signupDto);
747
- fail('Should have thrown NAuthException');
748
- } catch (error: any) {
749
- expect(error).toBeInstanceOf(NAuthException);
750
- expect(error.code).toBe(AuthErrorCode.SIGNUP_DISABLED);
751
- }
752
- });
753
- });
754
-
755
- describe('Database constraint violations', () => {
756
- it('should handle PostgreSQL unique constraint violation for email', async () => {
757
- mockUserRepository.findOne.mockResolvedValue(null);
758
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
759
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
760
- const dbError = {
761
- code: '23505',
762
- detail: 'Key (email)=(newuser@example.com) already exists.',
763
- message: 'duplicate key value violates unique constraint',
764
- };
765
- mockUserRepository.save.mockRejectedValue(dbError);
766
-
767
- try {
768
- await service.signup(signupDto);
769
- fail('Should have thrown NAuthException');
770
- } catch (error: any) {
771
- expect(error).toBeInstanceOf(NAuthException);
772
- expect(error.code).toBe(AuthErrorCode.EMAIL_EXISTS);
773
- }
774
- });
775
-
776
- it('should handle PostgreSQL unique constraint violation for username', async () => {
777
- mockUserRepository.findOne.mockResolvedValue(null);
778
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
779
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
780
- const dbError = {
781
- code: '23505',
782
- detail: 'Key (username)=(newuser) already exists.',
783
- message: 'duplicate key value violates unique constraint',
784
- };
785
- mockUserRepository.save.mockRejectedValue(dbError);
786
-
787
- try {
788
- await service.signup(signupDto);
789
- fail('Should have thrown NAuthException');
790
- } catch (error: any) {
791
- expect(error).toBeInstanceOf(NAuthException);
792
- expect(error.code).toBe(AuthErrorCode.USERNAME_EXISTS);
793
- }
794
- });
795
-
796
- it('should handle PostgreSQL unique constraint violation for phone', async () => {
797
- const signupDtoWithPhone: SignupDTO = {
798
- ...signupDto,
799
- phone: '+1234567890',
800
- };
801
- mockUserRepository.findOne.mockResolvedValue(null);
802
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
803
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
804
- const dbError = {
805
- code: '23505',
806
- detail: 'Key (phone)=(+1234567890) already exists.',
807
- message: 'duplicate key value violates unique constraint',
808
- };
809
- mockUserRepository.save.mockRejectedValue(dbError);
810
-
811
- try {
812
- await service.signup(signupDtoWithPhone);
813
- fail('Should have thrown NAuthException');
814
- } catch (error: any) {
815
- expect(error).toBeInstanceOf(NAuthException);
816
- expect(error.code).toBe(AuthErrorCode.PHONE_EXISTS);
817
- }
818
- });
819
- });
820
-
821
- describe('Audit logging', () => {
822
- it('should record ACCOUNT_CREATED audit event on successful signup', async () => {
823
- mockUserRepository.findOne.mockResolvedValue(null);
824
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
825
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
826
- mockUserRepository.create.mockReturnValue(mockUser as any);
827
- mockUserRepository.save.mockResolvedValue(mockUser as any);
828
-
829
- await service.signup(signupDto);
830
-
831
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
832
- (expect as any).objectContaining({
833
- userId: mockUser.id,
834
- eventType: AuthAuditEventType.ACCOUNT_CREATED,
835
- eventStatus: 'INFO',
836
- authMethod: 'password',
837
- }),
838
- );
839
- });
840
-
841
- it('should handle audit logging errors gracefully', async () => {
842
- mockUserRepository.findOne.mockResolvedValue(null);
843
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
844
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
845
- mockUserRepository.create.mockReturnValue(mockUser as any);
846
- mockUserRepository.save.mockResolvedValue(mockUser as any);
847
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
848
-
849
- const result = await service.signup(signupDto);
850
-
851
- // Should still succeed despite audit error
852
- expect(result.user).toBeDefined();
853
- expect(mockLogger.error).toHaveBeenCalled();
854
- });
855
- });
856
-
857
- describe('Optional fields', () => {
858
- it('should handle signup without username', async () => {
859
- const signupDtoNoUsername: SignupDTO = {
860
- email: 'newuser@example.com',
861
- password: 'SecurePassword123!',
862
- };
863
-
864
- mockUserRepository.findOne.mockResolvedValue(null);
865
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
866
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
867
- mockUserRepository.create.mockReturnValue(mockUser as any);
868
- mockUserRepository.save.mockResolvedValue(mockUser as any);
869
-
870
- await service.signup(signupDtoNoUsername);
871
-
872
- expect(mockUserRepository.findOne).not.toHaveBeenCalledWith(
873
- (expect as any).objectContaining({
874
- where: (expect as any).objectContaining({ username: (expect as any).anything() }),
875
- }),
876
- );
877
- });
878
-
879
- it('should handle signup with firstName and lastName', async () => {
880
- const signupDtoWithName: SignupDTO = {
881
- ...signupDto,
882
- firstName: 'John',
883
- lastName: 'Doe',
884
- };
885
-
886
- mockUserRepository.findOne.mockResolvedValue(null);
887
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
888
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
889
- mockUserRepository.create.mockReturnValue(mockUser as any);
890
- mockUserRepository.save.mockResolvedValue(mockUser as any);
891
-
892
- await service.signup(signupDtoWithName);
893
-
894
- expect(mockUserRepository.create).toHaveBeenCalledWith(
895
- (expect as any).objectContaining({
896
- firstName: 'John',
897
- lastName: 'Doe',
898
- }),
899
- );
900
- });
901
-
902
- it('should handle signup with metadata', async () => {
903
- const signupDtoWithMetadata: SignupDTO = {
904
- ...signupDto,
905
- metadata: { customField: 'value' },
906
- };
907
-
908
- mockUserRepository.findOne.mockResolvedValue(null);
909
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
910
- mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
911
- mockUserRepository.create.mockReturnValue(mockUser as any);
912
- mockUserRepository.save.mockResolvedValue(mockUser as any);
913
-
914
- await service.signup(signupDtoWithMetadata);
915
-
916
- expect(mockUserRepository.create).toHaveBeenCalledWith(
917
- (expect as any).objectContaining({
918
- metadata: { customField: 'value' },
919
- }),
920
- );
921
- });
922
- });
923
- });
924
-
925
- // ============================================================================
926
- // login Tests
927
- // ============================================================================
928
-
929
- describe('login()', () => {
930
- const loginDto: LoginDTO = {
931
- identifier: 'test@example.com',
932
- password: 'SecurePassword123!',
933
- };
934
-
935
- beforeEach(() => {
936
- mockChallengeHelper.determineAuthResponse.mockResolvedValue({
937
- accessToken: 'access-token',
938
- refreshToken: 'refresh-token',
939
- accessTokenExpiresAt: Math.floor(Date.now() / 1000) + 900,
940
- refreshTokenExpiresAt: Math.floor(Date.now() / 1000) + 604800,
941
- user: {
942
- sub: mockUser.sub,
943
- email: mockUser.email,
944
- isEmailVerified: true,
945
- isPhoneVerified: false,
946
- },
947
- });
948
- });
949
-
950
- describe('Successful login', () => {
951
- beforeEach(() => {
952
- const queryBuilder = {
953
- where: jest.fn().mockReturnThis(),
954
- orWhere: jest.fn().mockReturnThis(),
955
- select: jest.fn().mockReturnThis(),
956
- getOne: jest.fn().mockResolvedValue(mockUser),
957
- };
958
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
959
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
960
- mockPasswordService.verifyPassword.mockResolvedValue(true);
961
- mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
962
- mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
963
- mockJwtService.generateTokenPair.mockResolvedValue({
964
- accessToken: 'access-token',
965
- refreshToken: 'refresh-token',
966
- expiresIn: 900,
967
- });
968
- mockJwtService.hashToken.mockReturnValue('token-hash');
969
- mockJwtService.validateAccessToken.mockResolvedValue({
970
- valid: true,
971
- payload: { exp: Math.floor(Date.now() / 1000) + 900 },
972
- } as any);
973
- mockJwtService.validateRefreshToken.mockResolvedValue({
974
- valid: true,
975
- payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
976
- } as any);
977
- mockSessionService.createSessionAtomic.mockResolvedValue({
978
- session: mockSession,
979
- extra: {
980
- accessToken: 'access-token',
981
- refreshToken: 'refresh-token',
982
- },
983
- } as any);
984
- mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
985
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
986
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
987
- });
988
-
989
- it('should login user successfully and return tokens', async () => {
990
- const result = await service.login(loginDto);
991
-
992
- expect(result.user).toBeDefined();
993
- expect(result.accessToken).toBe('access-token');
994
- expect(result.refreshToken).toBe('refresh-token');
995
- expect(mockPasswordService.verifyPassword).toHaveBeenCalledWith(loginDto.password, mockUser.passwordHash!);
996
- expect(mockSessionService.createSessionAtomic).toHaveBeenCalled();
997
- expect(mockAccountLockoutStorage.resetFailedAttempts).toHaveBeenCalled();
998
- });
999
-
1000
- it('should update user lastLoginAt and lastLoginIp on successful login', async () => {
1001
- await service.login(loginDto);
1002
-
1003
- expect(mockUserRepository.update).toHaveBeenCalledWith(
1004
- mockUser.id,
1005
- (expect as any).objectContaining({
1006
- lastLoginAt: (expect as any).any(Date),
1007
- lastLoginIp: mockClientInfo.ipAddress!,
1008
- failedLoginAttempts: 0,
1009
- }),
1010
- );
1011
- });
1012
-
1013
- it('should record successful login attempt', async () => {
1014
- await service.login(loginDto);
1015
-
1016
- expect(mockLoginAttemptRepository.create).toHaveBeenCalled();
1017
- expect(mockLoginAttemptRepository.save).toHaveBeenCalled();
1018
- });
1019
-
1020
- it('should reset failed login attempts on successful login', async () => {
1021
- await service.login(loginDto);
1022
-
1023
- expect(mockUserRepository.update).toHaveBeenCalledWith(
1024
- mockUser.id,
1025
- (expect as any).objectContaining({
1026
- failedLoginAttempts: 0,
1027
- }),
1028
- );
1029
- });
1030
-
1031
- it('should record LOGIN_SUCCESS audit event', async () => {
1032
- await service.login(loginDto);
1033
-
1034
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
1035
- (expect as any).objectContaining({
1036
- userId: mockUser.id,
1037
- eventType: AuthAuditEventType.LOGIN_SUCCESS,
1038
- eventStatus: 'SUCCESS',
1039
- authMethod: 'password',
1040
- }),
1041
- );
1042
- });
1043
-
1044
- it('should execute afterLogin hook on successful login', async () => {
1045
- mockConfig.hooks = {
1046
- afterLogin: jest.fn().mockResolvedValue(undefined),
1047
- };
1048
-
1049
- await service.login(loginDto);
1050
-
1051
- expect(mockConfig.hooks!.afterLogin).toHaveBeenCalledWith(mockUser, mockSession);
1052
- });
1053
- });
1054
-
1055
- describe('User lookup by identifier', () => {
1056
- it('should find user by email', async () => {
1057
- const queryBuilder = {
1058
- where: jest.fn().mockReturnThis(),
1059
- orWhere: jest.fn().mockReturnThis(),
1060
- select: jest.fn().mockReturnThis(),
1061
- getOne: jest.fn().mockResolvedValue(mockUser),
1062
- };
1063
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1064
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1065
- mockPasswordService.verifyPassword.mockResolvedValue(true);
1066
- mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
1067
- mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
1068
- mockJwtService.generateTokenPair.mockResolvedValue({
1069
- accessToken: 'access-token',
1070
- refreshToken: 'refresh-token',
1071
- expiresIn: 900,
1072
- });
1073
- mockJwtService.hashToken.mockReturnValue('token-hash');
1074
- mockJwtService.validateAccessToken.mockResolvedValue({
1075
- valid: true,
1076
- payload: { exp: Math.floor(Date.now() / 1000) + 900 },
1077
- } as any);
1078
- mockJwtService.validateRefreshToken.mockResolvedValue({
1079
- valid: true,
1080
- payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
1081
- } as any);
1082
- mockSessionService.createSessionAtomic.mockResolvedValue({
1083
- session: mockSession,
1084
- extra: {
1085
- accessToken: 'access-token',
1086
- refreshToken: 'refresh-token',
1087
- },
1088
- } as any);
1089
- mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
1090
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1091
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1092
-
1093
- await service.login({ identifier: 'test@example.com', password: 'password' });
1094
-
1095
- expect(queryBuilder.where).toHaveBeenCalledWith('user.email = :identifier', { identifier: 'test@example.com' });
1096
- });
1097
-
1098
- it('should find user by username when identifierType is email_or_username', async () => {
1099
- mockConfig.login!.identifierType = 'email_or_username';
1100
- const queryBuilder = {
1101
- where: jest.fn().mockReturnThis(),
1102
- orWhere: jest.fn().mockReturnThis(),
1103
- select: jest.fn().mockReturnThis(),
1104
- getOne: jest.fn().mockResolvedValue(mockUser),
1105
- };
1106
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1107
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1108
- mockPasswordService.verifyPassword.mockResolvedValue(true);
1109
- mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
1110
- mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
1111
- mockJwtService.generateTokenPair.mockResolvedValue({
1112
- accessToken: 'access-token',
1113
- refreshToken: 'refresh-token',
1114
- expiresIn: 900,
1115
- });
1116
- mockJwtService.hashToken.mockReturnValue('token-hash');
1117
- mockJwtService.validateAccessToken.mockResolvedValue({
1118
- valid: true,
1119
- payload: { exp: Math.floor(Date.now() / 1000) + 900 },
1120
- } as any);
1121
- mockJwtService.validateRefreshToken.mockResolvedValue({
1122
- valid: true,
1123
- payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
1124
- } as any);
1125
- mockSessionService.createSessionAtomic.mockResolvedValue({
1126
- session: mockSession,
1127
- extra: {
1128
- accessToken: 'access-token',
1129
- refreshToken: 'refresh-token',
1130
- },
1131
- } as any);
1132
- mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
1133
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1134
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1135
-
1136
- await service.login({ identifier: 'testuser', password: 'password' });
1137
-
1138
- expect(queryBuilder.where).toHaveBeenCalled();
1139
- expect(queryBuilder.orWhere).toHaveBeenCalled();
1140
- });
1141
-
1142
- it('should throw NAuthException when identifierType is email but username provided', async () => {
1143
- mockConfig.login!.identifierType = 'email';
1144
- const queryBuilder = {
1145
- where: jest.fn().mockReturnThis(),
1146
- orWhere: jest.fn().mockReturnThis(),
1147
- select: jest.fn().mockReturnThis(),
1148
- getOne: jest.fn().mockResolvedValue(null),
1149
- };
1150
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1151
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1152
- mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
1153
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1154
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1155
-
1156
- try {
1157
- await service.login({ identifier: 'testuser', password: 'password' });
1158
- fail('Should have thrown NAuthException');
1159
- } catch (error: any) {
1160
- expect(error).toBeInstanceOf(NAuthException);
1161
- expect(error.code).toBe(AuthErrorCode.INVALID_CREDENTIALS);
1162
- }
1163
- });
1164
- });
1165
-
1166
- describe('IP-based lockout', () => {
1167
- it('should throw NAuthException if IP address is locked', async () => {
1168
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(true);
1169
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1170
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1171
-
1172
- try {
1173
- await service.login(loginDto);
1174
- fail('Should have thrown NAuthException');
1175
- } catch (error: any) {
1176
- expect(error).toBeInstanceOf(NAuthException);
1177
- expect(error.code).toBe(AuthErrorCode.RATE_LIMIT_LOGIN);
1178
- }
1179
- });
1180
-
1181
- it('should record LOGIN_BLOCKED audit event when IP is locked', async () => {
1182
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(true);
1183
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1184
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1185
-
1186
- try {
1187
- await service.login(loginDto);
1188
- fail('Should have thrown NAuthException');
1189
- } catch (error: any) {
1190
- // Expected to throw
1191
- }
1192
-
1193
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
1194
- (expect as any).objectContaining({
1195
- eventType: AuthAuditEventType.LOGIN_BLOCKED,
1196
- eventStatus: 'FAILURE',
1197
- reason: 'ip_locked',
1198
- }),
1199
- );
1200
- });
1201
-
1202
- it('should reset IP-based failed attempts on successful login when resetOnSuccess is true', async () => {
1203
- mockConfig.lockout!.resetOnSuccess = true;
1204
- const queryBuilder = {
1205
- where: jest.fn().mockReturnThis(),
1206
- orWhere: jest.fn().mockReturnThis(),
1207
- select: jest.fn().mockReturnThis(),
1208
- getOne: jest.fn().mockResolvedValue(mockUser),
1209
- };
1210
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1211
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1212
- mockPasswordService.verifyPassword.mockResolvedValue(true);
1213
- mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
1214
- mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
1215
- mockJwtService.generateTokenPair.mockResolvedValue({
1216
- accessToken: 'access-token',
1217
- refreshToken: 'refresh-token',
1218
- expiresIn: 900,
1219
- });
1220
- mockJwtService.hashToken.mockReturnValue('token-hash');
1221
- mockJwtService.validateAccessToken.mockResolvedValue({
1222
- valid: true,
1223
- payload: { exp: Math.floor(Date.now() / 1000) + 900 },
1224
- } as any);
1225
- mockJwtService.validateRefreshToken.mockResolvedValue({
1226
- valid: true,
1227
- payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
1228
- } as any);
1229
- mockSessionService.createSessionAtomic.mockResolvedValue({
1230
- session: mockSession,
1231
- extra: {
1232
- accessToken: 'access-token',
1233
- refreshToken: 'refresh-token',
1234
- },
1235
- } as any);
1236
- mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
1237
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1238
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1239
-
1240
- await service.login(loginDto);
1241
-
1242
- expect(mockAccountLockoutStorage.resetFailedAttempts).toHaveBeenCalledWith(mockClientInfo.ipAddress);
1243
- });
1244
- });
1245
-
1246
- describe('Invalid credentials', () => {
1247
- it('should throw NAuthException if user not found (constant-time response)', async () => {
1248
- const queryBuilder = {
1249
- where: jest.fn().mockReturnThis(),
1250
- orWhere: jest.fn().mockReturnThis(),
1251
- select: jest.fn().mockReturnThis(),
1252
- getOne: jest.fn().mockResolvedValue(null),
1253
- };
1254
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1255
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1256
- // Password verification still called with dummy hash (constant-time)
1257
- mockPasswordService.verifyPassword.mockResolvedValue(false);
1258
- mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
1259
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1260
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1261
-
1262
- try {
1263
- await service.login(loginDto);
1264
- fail('Should have thrown NAuthException');
1265
- } catch (error: any) {
1266
- expect(error).toBeInstanceOf(NAuthException);
1267
- expect(error.code).toBe(AuthErrorCode.INVALID_CREDENTIALS);
1268
- }
1269
- // Verify password was still called (constant-time protection)
1270
- expect(mockPasswordService.verifyPassword).toHaveBeenCalled();
1271
- });
1272
-
1273
- it('should throw NAuthException if password is invalid', async () => {
1274
- const queryBuilder = {
1275
- where: jest.fn().mockReturnThis(),
1276
- orWhere: jest.fn().mockReturnThis(),
1277
- select: jest.fn().mockReturnThis(),
1278
- getOne: jest.fn().mockResolvedValue(mockUser),
1279
- };
1280
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1281
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1282
- mockPasswordService.verifyPassword.mockResolvedValue(false);
1283
- mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
1284
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1285
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1286
-
1287
- try {
1288
- await service.login(loginDto);
1289
- fail('Should have thrown NAuthException');
1290
- } catch (error: any) {
1291
- expect(error).toBeInstanceOf(NAuthException);
1292
- expect(error.code).toBe(AuthErrorCode.INVALID_CREDENTIALS);
1293
- }
1294
- expect(mockAccountLockoutStorage.recordFailedAttempt).toHaveBeenCalled();
1295
- });
1296
-
1297
- it('should record failed login attempt for invalid credentials', async () => {
1298
- const queryBuilder = {
1299
- where: jest.fn().mockReturnThis(),
1300
- orWhere: jest.fn().mockReturnThis(),
1301
- select: jest.fn().mockReturnThis(),
1302
- getOne: jest.fn().mockResolvedValue(mockUser),
1303
- };
1304
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1305
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1306
- mockPasswordService.verifyPassword.mockResolvedValue(false);
1307
- mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
1308
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1309
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1310
-
1311
- try {
1312
- await service.login(loginDto);
1313
- fail('Should have thrown NAuthException');
1314
- } catch (error: any) {
1315
- // Expected
1316
- }
1317
-
1318
- expect(mockLoginAttemptRepository.create).toHaveBeenCalled();
1319
- expect(mockLoginAttemptRepository.save).toHaveBeenCalled();
1320
- });
1321
-
1322
- it('should record LOGIN_FAILED audit event for invalid credentials', async () => {
1323
- const queryBuilder = {
1324
- where: jest.fn().mockReturnThis(),
1325
- orWhere: jest.fn().mockReturnThis(),
1326
- select: jest.fn().mockReturnThis(),
1327
- getOne: jest.fn().mockResolvedValue(mockUser),
1328
- };
1329
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1330
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1331
- mockPasswordService.verifyPassword.mockResolvedValue(false);
1332
- mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
1333
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1334
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1335
-
1336
- try {
1337
- await service.login(loginDto);
1338
- fail('Should have thrown NAuthException');
1339
- } catch (error: any) {
1340
- // Expected
1341
- }
1342
-
1343
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
1344
- (expect as any).objectContaining({
1345
- userId: mockUser.id,
1346
- eventType: AuthAuditEventType.LOGIN_FAILED,
1347
- eventStatus: 'FAILURE',
1348
- reason: 'invalid_credentials',
1349
- }),
1350
- );
1351
- });
1352
-
1353
- it('should provide helpful error for social-only users', async () => {
1354
- const socialUser = { ...mockUser, passwordHash: null, socialProviders: ['google'] };
1355
- const queryBuilder = {
1356
- where: jest.fn().mockReturnThis(),
1357
- orWhere: jest.fn().mockReturnThis(),
1358
- select: jest.fn().mockReturnThis(),
1359
- getOne: jest.fn().mockResolvedValue(socialUser),
1360
- };
1361
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1362
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1363
- mockPasswordService.verifyPassword.mockResolvedValue(false);
1364
- mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
1365
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1366
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1367
-
1368
- try {
1369
- await service.login(loginDto);
1370
- fail('Should have thrown NAuthException');
1371
- } catch (error: any) {
1372
- expect(error).toBeInstanceOf(NAuthException);
1373
- expect(error.code).toBe(AuthErrorCode.INVALID_CREDENTIALS);
1374
- expect(error.message).toContain('Google');
1375
- }
1376
- });
1377
-
1378
- it('should execute afterLoginFailed hook on failed login', async () => {
1379
- mockConfig.hooks = {
1380
- afterLoginFailed: jest.fn().mockResolvedValue(undefined),
1381
- };
1382
- const queryBuilder = {
1383
- where: jest.fn().mockReturnThis(),
1384
- orWhere: jest.fn().mockReturnThis(),
1385
- select: jest.fn().mockReturnThis(),
1386
- getOne: jest.fn().mockResolvedValue(mockUser),
1387
- };
1388
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1389
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1390
- mockPasswordService.verifyPassword.mockResolvedValue(false);
1391
- mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
1392
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1393
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1394
-
1395
- try {
1396
- await service.login(loginDto);
1397
- fail('Should have thrown NAuthException');
1398
- } catch (error: any) {
1399
- // Expected
1400
- }
1401
-
1402
- expect(mockConfig.hooks!.afterLoginFailed).toHaveBeenCalledWith(loginDto.identifier, 'invalid_credentials');
1403
- });
1404
- });
1405
-
1406
- describe('Account status checks', () => {
1407
- it('should throw NAuthException if account is inactive', async () => {
1408
- const inactiveUser = { ...mockUser, isActive: false };
1409
- const queryBuilder = {
1410
- where: jest.fn().mockReturnThis(),
1411
- orWhere: jest.fn().mockReturnThis(),
1412
- select: jest.fn().mockReturnThis(),
1413
- getOne: jest.fn().mockResolvedValue(inactiveUser),
1414
- };
1415
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1416
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1417
- mockPasswordService.verifyPassword.mockResolvedValue(true);
1418
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1419
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1420
-
1421
- try {
1422
- await service.login(loginDto);
1423
- fail('Should have thrown NAuthException');
1424
- } catch (error: any) {
1425
- expect(error).toBeInstanceOf(NAuthException);
1426
- expect(error.code).toBe(AuthErrorCode.ACCOUNT_INACTIVE);
1427
- }
1428
- });
1429
-
1430
- it('should record LOGIN_BLOCKED audit event when account is inactive', async () => {
1431
- const inactiveUser = { ...mockUser, isActive: false };
1432
- const queryBuilder = {
1433
- where: jest.fn().mockReturnThis(),
1434
- orWhere: jest.fn().mockReturnThis(),
1435
- select: jest.fn().mockReturnThis(),
1436
- getOne: jest.fn().mockResolvedValue(inactiveUser),
1437
- };
1438
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1439
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1440
- mockPasswordService.verifyPassword.mockResolvedValue(true);
1441
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1442
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1443
-
1444
- try {
1445
- await service.login(loginDto);
1446
- fail('Should have thrown NAuthException');
1447
- } catch (error: any) {
1448
- // Expected
1449
- }
1450
-
1451
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
1452
- (expect as any).objectContaining({
1453
- eventType: AuthAuditEventType.LOGIN_BLOCKED,
1454
- eventStatus: 'FAILURE',
1455
- reason: 'account_inactive',
1456
- }),
1457
- );
1458
- });
1459
- });
1460
-
1461
- describe('Lifecycle hooks', () => {
1462
- it('should execute beforeLogin hook and reject if returns false', async () => {
1463
- mockConfig.hooks = {
1464
- beforeLogin: jest.fn().mockResolvedValue(false),
1465
- };
1466
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1467
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1468
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1469
-
1470
- try {
1471
- await service.login(loginDto);
1472
- fail('Should have thrown NAuthException');
1473
- } catch (error: any) {
1474
- expect(error).toBeInstanceOf(NAuthException);
1475
- expect(error.code).toBe(AuthErrorCode.FORBIDDEN);
1476
- expect(mockConfig.hooks!.beforeLogin).toHaveBeenCalledWith(loginDto.identifier);
1477
- }
1478
- });
1479
-
1480
- it('should allow login if beforeLogin hook returns true', async () => {
1481
- mockConfig.hooks = {
1482
- beforeLogin: jest.fn().mockResolvedValue(true),
1483
- };
1484
- const queryBuilder = {
1485
- where: jest.fn().mockReturnThis(),
1486
- orWhere: jest.fn().mockReturnThis(),
1487
- select: jest.fn().mockReturnThis(),
1488
- getOne: jest.fn().mockResolvedValue(mockUser),
1489
- };
1490
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1491
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1492
- mockPasswordService.verifyPassword.mockResolvedValue(true);
1493
- mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
1494
- mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
1495
- mockJwtService.generateTokenPair.mockResolvedValue({
1496
- accessToken: 'access-token',
1497
- refreshToken: 'refresh-token',
1498
- expiresIn: 900,
1499
- });
1500
- mockJwtService.hashToken.mockReturnValue('token-hash');
1501
- mockJwtService.validateAccessToken.mockResolvedValue({
1502
- valid: true,
1503
- payload: { exp: Math.floor(Date.now() / 1000) + 900 },
1504
- } as any);
1505
- mockJwtService.validateRefreshToken.mockResolvedValue({
1506
- valid: true,
1507
- payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
1508
- } as any);
1509
- mockSessionService.createSessionAtomic.mockResolvedValue({
1510
- session: mockSession,
1511
- extra: {
1512
- accessToken: 'access-token',
1513
- refreshToken: 'refresh-token',
1514
- },
1515
- } as any);
1516
- mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
1517
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1518
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1519
-
1520
- await service.login(loginDto);
1521
-
1522
- expect(mockConfig.hooks!.beforeLogin).toHaveBeenCalled();
1523
- expect(mockSessionService.createSessionAtomic).toHaveBeenCalled();
1524
- });
1525
- });
1526
-
1527
- describe('Challenge system', () => {
1528
- it('should return VERIFY_EMAIL challenge when email not verified', async () => {
1529
- const unverifiedUser = { ...mockUser, isEmailVerified: false };
1530
- const queryBuilder = {
1531
- where: jest.fn().mockReturnThis(),
1532
- orWhere: jest.fn().mockReturnThis(),
1533
- select: jest.fn().mockReturnThis(),
1534
- getOne: jest.fn().mockResolvedValue(unverifiedUser),
1535
- };
1536
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1537
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1538
- mockPasswordService.verifyPassword.mockResolvedValue(true);
1539
- mockChallengeHelper.determineAuthResponse.mockResolvedValue({
1540
- challengeName: AuthChallenge.VERIFY_EMAIL,
1541
- session: 'challenge-session',
1542
- challengeParameters: {
1543
- email: unverifiedUser.email,
1544
- },
1545
- });
1546
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1547
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1548
-
1549
- const result = await service.login(loginDto);
1550
-
1551
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
1552
- expect(result.accessToken).toBeUndefined();
1553
- });
1554
-
1555
- it('should return FORCE_CHANGE_PASSWORD challenge when mustChangePassword is true', async () => {
1556
- const userWithMustChange = { ...mockUser, mustChangePassword: true };
1557
- const queryBuilder = {
1558
- where: jest.fn().mockReturnThis(),
1559
- orWhere: jest.fn().mockReturnThis(),
1560
- select: jest.fn().mockReturnThis(),
1561
- getOne: jest.fn().mockResolvedValue(userWithMustChange),
1562
- };
1563
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1564
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1565
- mockPasswordService.verifyPassword.mockResolvedValue(true);
1566
- mockChallengeHelper.determineAuthResponse.mockResolvedValue({
1567
- challengeName: AuthChallenge.FORCE_CHANGE_PASSWORD,
1568
- session: 'challenge-session',
1569
- challengeParameters: {
1570
- instructions: 'You must change your password',
1571
- },
1572
- });
1573
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1574
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1575
-
1576
- const result = await service.login(loginDto);
1577
-
1578
- expect(result.challengeName).toBe(AuthChallenge.FORCE_CHANGE_PASSWORD);
1579
- });
1580
-
1581
- it('should return MFA_REQUIRED challenge when MFA is required', async () => {
1582
- const queryBuilder = {
1583
- where: jest.fn().mockReturnThis(),
1584
- orWhere: jest.fn().mockReturnThis(),
1585
- select: jest.fn().mockReturnThis(),
1586
- getOne: jest.fn().mockResolvedValue(mockUser),
1587
- };
1588
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1589
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1590
- mockPasswordService.verifyPassword.mockResolvedValue(true);
1591
- mockChallengeHelper.determineAuthResponse.mockResolvedValue({
1592
- challengeName: AuthChallenge.MFA_REQUIRED,
1593
- session: 'mfa-session',
1594
- challengeParameters: {},
1595
- });
1596
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1597
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1598
-
1599
- const result = await service.login(loginDto);
1600
-
1601
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
1602
- });
1603
-
1604
- it('should handle password expiry and force password change', async () => {
1605
- mockConfig.password!.expiryDays = 90;
1606
- const userWithExpiredPassword = {
1607
- ...mockUser,
1608
- passwordChangedAt: new Date(Date.now() - 91 * 24 * 60 * 60 * 1000), // 91 days ago
1609
- };
1610
- const queryBuilder = {
1611
- where: jest.fn().mockReturnThis(),
1612
- orWhere: jest.fn().mockReturnThis(),
1613
- select: jest.fn().mockReturnThis(),
1614
- getOne: jest.fn().mockResolvedValue(userWithExpiredPassword),
1615
- };
1616
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1617
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1618
- mockPasswordService.verifyPassword.mockResolvedValue(true);
1619
- mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
1620
- mockChallengeHelper.determineAuthResponse.mockResolvedValue({
1621
- challengeName: AuthChallenge.FORCE_CHANGE_PASSWORD,
1622
- session: 'challenge-session',
1623
- challengeParameters: {},
1624
- });
1625
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1626
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1627
-
1628
- const result = await service.login(loginDto);
1629
-
1630
- expect(result.challengeName).toBe(AuthChallenge.FORCE_CHANGE_PASSWORD);
1631
- expect(mockUserRepository.update).toHaveBeenCalledWith(
1632
- userWithExpiredPassword.id,
1633
- (expect as any).objectContaining({
1634
- mustChangePassword: true,
1635
- }),
1636
- );
1637
- });
1638
- });
1639
-
1640
- describe('Single session mode', () => {
1641
- it('should revoke other sessions when disallowMultipleSessions is enabled', async () => {
1642
- mockConfig.session!.disallowMultipleSessions = true;
1643
- const queryBuilder = {
1644
- where: jest.fn().mockReturnThis(),
1645
- orWhere: jest.fn().mockReturnThis(),
1646
- select: jest.fn().mockReturnThis(),
1647
- getOne: jest.fn().mockResolvedValue(mockUser),
1648
- };
1649
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1650
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1651
- mockPasswordService.verifyPassword.mockResolvedValue(true);
1652
- mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
1653
- mockSessionService.revokeAllUserSessions.mockResolvedValue(2);
1654
- mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
1655
- mockJwtService.generateTokenPair.mockResolvedValue({
1656
- accessToken: 'access-token',
1657
- refreshToken: 'refresh-token',
1658
- expiresIn: 900,
1659
- });
1660
- mockJwtService.hashToken.mockReturnValue('token-hash');
1661
- mockJwtService.validateAccessToken.mockResolvedValue({
1662
- valid: true,
1663
- payload: { exp: Math.floor(Date.now() / 1000) + 900 },
1664
- } as any);
1665
- mockJwtService.validateRefreshToken.mockResolvedValue({
1666
- valid: true,
1667
- payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
1668
- } as any);
1669
- mockSessionService.createSessionAtomic.mockResolvedValue({
1670
- session: mockSession,
1671
- extra: {
1672
- accessToken: 'access-token',
1673
- refreshToken: 'refresh-token',
1674
- },
1675
- } as any);
1676
- mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
1677
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1678
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1679
-
1680
- await service.login(loginDto);
1681
-
1682
- expect(mockSessionService.revokeAllUserSessions).toHaveBeenCalledWith(mockUser.id, 'Login from new session');
1683
- });
1684
- });
1685
-
1686
- describe('Trusted device management', () => {
1687
- it('should check if device is already trusted', async () => {
1688
- mockConfig.mfa = {
1689
- rememberDevices: 'always',
1690
- rememberDeviceDays: 30,
1691
- };
1692
- mockClientInfo.deviceToken = 'existing-device-token';
1693
- mockTrustedDeviceService.isDeviceTrusted.mockResolvedValue(true);
1694
- const queryBuilder = {
1695
- where: jest.fn().mockReturnThis(),
1696
- orWhere: jest.fn().mockReturnThis(),
1697
- select: jest.fn().mockReturnThis(),
1698
- getOne: jest.fn().mockResolvedValue(mockUser),
1699
- };
1700
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1701
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1702
- mockPasswordService.verifyPassword.mockResolvedValue(true);
1703
- mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
1704
- mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
1705
- mockJwtService.generateTokenPair.mockResolvedValue({
1706
- accessToken: 'access-token',
1707
- refreshToken: 'refresh-token',
1708
- expiresIn: 900,
1709
- });
1710
- mockJwtService.hashToken.mockReturnValue('token-hash');
1711
- mockJwtService.validateAccessToken.mockResolvedValue({
1712
- valid: true,
1713
- payload: { exp: Math.floor(Date.now() / 1000) + 900 },
1714
- } as any);
1715
- mockJwtService.validateRefreshToken.mockResolvedValue({
1716
- valid: true,
1717
- payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
1718
- } as any);
1719
- mockSessionService.createSessionAtomic.mockResolvedValue({
1720
- session: mockSession,
1721
- extra: {
1722
- accessToken: 'access-token',
1723
- refreshToken: 'refresh-token',
1724
- },
1725
- } as any);
1726
- mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
1727
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1728
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1729
-
1730
- const result = await service.login(loginDto);
1731
-
1732
- expect(mockTrustedDeviceService.isDeviceTrusted).toHaveBeenCalledWith('existing-device-token', mockUser.id);
1733
- expect(result.trusted).toBe(true);
1734
- });
1735
-
1736
- it('should auto-create trusted device when rememberDevices is always', async () => {
1737
- mockConfig.mfa = {
1738
- rememberDevices: 'always',
1739
- rememberDeviceDays: 30,
1740
- };
1741
- mockTrustedDeviceService.isDeviceTrusted.mockResolvedValue(false);
1742
- mockTrustedDeviceService.createTrustedDevice.mockResolvedValue('new-device-token');
1743
- const queryBuilder = {
1744
- where: jest.fn().mockReturnThis(),
1745
- orWhere: jest.fn().mockReturnThis(),
1746
- select: jest.fn().mockReturnThis(),
1747
- getOne: jest.fn().mockResolvedValue(mockUser),
1748
- };
1749
- mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
1750
- mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
1751
- mockPasswordService.verifyPassword.mockResolvedValue(true);
1752
- mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
1753
- mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
1754
- mockJwtService.generateTokenPair.mockResolvedValue({
1755
- accessToken: 'access-token',
1756
- refreshToken: 'refresh-token',
1757
- expiresIn: 900,
1758
- });
1759
- mockJwtService.hashToken.mockReturnValue('token-hash');
1760
- mockJwtService.validateAccessToken.mockResolvedValue({
1761
- valid: true,
1762
- payload: { exp: Math.floor(Date.now() / 1000) + 900 },
1763
- } as any);
1764
- mockJwtService.validateRefreshToken.mockResolvedValue({
1765
- valid: true,
1766
- payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
1767
- } as any);
1768
- mockSessionService.createSessionAtomic.mockResolvedValue({
1769
- session: mockSession,
1770
- extra: {
1771
- accessToken: 'access-token',
1772
- refreshToken: 'refresh-token',
1773
- },
1774
- } as any);
1775
- mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
1776
- mockLoginAttemptRepository.create.mockReturnValue({} as any);
1777
- mockLoginAttemptRepository.save.mockResolvedValue({} as any);
1778
-
1779
- const result = await service.login(loginDto);
1780
-
1781
- expect(mockTrustedDeviceService.createTrustedDevice).toHaveBeenCalled();
1782
- expect(result.deviceToken).toBe('new-device-token');
1783
- expect(result.trusted).toBe(true);
1784
- });
1785
- });
1786
- });
1787
-
1788
- // ============================================================================
1789
- // refreshToken Tests
1790
- // ============================================================================
1791
-
1792
- describe('refreshToken()', () => {
1793
- const mockRefreshToken = 'refresh-token-123';
1794
- const mockTokenHash = 'token-hash-123';
1795
- const mockPayload = {
1796
- sub: mockUser.sub,
1797
- email: mockUser.email,
1798
- type: 'refresh' as const,
1799
- sessionId: '1',
1800
- tokenFamily: 'family-abc',
1801
- iat: Math.floor(Date.now() / 1000),
1802
- exp: Math.floor(Date.now() / 1000) + 3600,
1803
- };
1804
-
1805
- beforeEach(() => {
1806
- mockJwtService.hashToken.mockReturnValue(mockTokenHash);
1807
- mockJwtService.validateRefreshToken.mockResolvedValue({
1808
- valid: true,
1809
- payload: mockPayload,
1810
- } as any);
1811
- mockJwtService.validateAccessToken.mockResolvedValue({
1812
- valid: true,
1813
- payload: { exp: Math.floor(Date.now() / 1000) + 900 },
1814
- } as any);
1815
- mockSessionService.findByRefreshToken.mockResolvedValue(mockSession);
1816
- mockSessionService.findByIdLight.mockResolvedValue(mockSession);
1817
- mockSessionService.acquireRefreshLock.mockResolvedValue(true);
1818
- mockSessionService.isRefreshTokenUsed.mockResolvedValue(false);
1819
- mockSessionService.markRefreshTokenAsUsed.mockResolvedValue(true);
1820
- mockJwtService.generateTokenPair.mockResolvedValue({
1821
- accessToken: 'new-access-token',
1822
- refreshToken: 'new-refresh-token',
1823
- expiresIn: 900,
1824
- });
1825
- mockJwtService.decodeToken.mockReturnValue(mockPayload as any);
1826
- mockSessionService.updateTokens.mockResolvedValue(undefined);
1827
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
1828
- });
1829
-
1830
- describe('Successful token refresh', () => {
1831
- it('should refresh tokens successfully', async () => {
1832
- const result = await service.refreshToken(mockRefreshToken);
1833
-
1834
- expect(result.accessToken).toBe('new-access-token');
1835
- expect(result.refreshToken).toBe('new-refresh-token');
1836
- expect(mockJwtService.hashToken).toHaveBeenCalledWith(mockRefreshToken);
1837
- expect(mockSessionService.findByRefreshToken).toHaveBeenCalledWith(mockTokenHash);
1838
- expect(mockSessionService.acquireRefreshLock).toHaveBeenCalled();
1839
- expect(mockJwtService.validateRefreshToken).toHaveBeenCalledWith(mockRefreshToken);
1840
- expect(mockSessionService.updateTokens).toHaveBeenCalled();
1841
- });
1842
-
1843
- it('should acquire distributed lock before validation', async () => {
1844
- await service.refreshToken(mockRefreshToken);
1845
-
1846
- const lockCall = mockSessionService.acquireRefreshLock.mock.calls[0];
1847
- expect(lockCall[0]).toContain('session-refresh:');
1848
- expect(lockCall[0]).toContain(mockSession.id.toString());
1849
- });
1850
-
1851
- it('should mark refresh token as used when reuseDetection is enabled', async () => {
1852
- mockConfig.jwt.refreshToken.reuseDetection = true;
1853
-
1854
- await service.refreshToken(mockRefreshToken);
1855
-
1856
- expect(mockSessionService.markRefreshTokenAsUsed).toHaveBeenCalledWith(
1857
- mockTokenHash,
1858
- mockJwtService.getRefreshTokenTTL(),
1859
- );
1860
- });
1861
-
1862
- it('should not mark token as used when reuseDetection is disabled', async () => {
1863
- mockConfig.jwt.refreshToken.reuseDetection = false;
1864
-
1865
- await service.refreshToken(mockRefreshToken);
1866
-
1867
- expect(mockSessionService.markRefreshTokenAsUsed).not.toHaveBeenCalled();
1868
- });
1869
-
1870
- it('should rotate refresh token (generate new token pair)', async () => {
1871
- await service.refreshToken(mockRefreshToken);
1872
-
1873
- expect(mockJwtService.generateTokenPair).toHaveBeenCalledWith({
1874
- userId: mockUser.sub,
1875
- email: mockUser.email,
1876
- sessionId: mockSession.id.toString(),
1877
- tokenFamily: mockSession.tokenFamily,
1878
- });
1879
- });
1880
-
1881
- it('should update session with new token hashes', async () => {
1882
- mockJwtService.hashToken
1883
- .mockReturnValueOnce(mockTokenHash) // For initial hash
1884
- .mockReturnValueOnce('new-access-hash')
1885
- .mockReturnValueOnce('new-refresh-hash');
1886
-
1887
- await service.refreshToken(mockRefreshToken);
1888
-
1889
- expect(mockSessionService.updateTokens).toHaveBeenCalledWith(
1890
- mockSession.id,
1891
- 'new-access-hash',
1892
- 'new-refresh-hash',
1893
- );
1894
- });
1895
-
1896
- it('should return token expiry times', async () => {
1897
- const accessExp = Math.floor(Date.now() / 1000) + 900;
1898
- const refreshExp = Math.floor(Date.now() / 1000) + 604800;
1899
- mockJwtService.validateAccessToken.mockResolvedValue({
1900
- valid: true,
1901
- payload: { exp: accessExp },
1902
- } as any);
1903
- mockJwtService.validateRefreshToken.mockResolvedValue({
1904
- valid: true,
1905
- payload: { exp: refreshExp },
1906
- } as any);
1907
-
1908
- const result = await service.refreshToken(mockRefreshToken);
1909
-
1910
- expect(result.accessTokenExpiresAt).toBe(accessExp);
1911
- expect(result.refreshTokenExpiresAt).toBe(refreshExp);
1912
- });
1913
-
1914
- it('should release lock after successful refresh', async () => {
1915
- await service.refreshToken(mockRefreshToken);
1916
-
1917
- expect(mockSessionService.releaseRefreshLock).toHaveBeenCalled();
1918
- });
1919
- });
1920
-
1921
- describe('Invalid token handling', () => {
1922
- it('should throw NAuthException if refresh token is invalid', async () => {
1923
- mockJwtService.validateRefreshToken.mockResolvedValue({
1924
- valid: false,
1925
- payload: undefined,
1926
- } as any);
1927
-
1928
- try {
1929
- await service.refreshToken('invalid-token');
1930
- fail('Should have thrown NAuthException');
1931
- } catch (error: any) {
1932
- expect(error).toBeInstanceOf(NAuthException);
1933
- expect(error.code).toBe(AuthErrorCode.TOKEN_INVALID);
1934
- }
1935
- });
1936
-
1937
- it('should throw NAuthException if session not found', async () => {
1938
- mockSessionService.findByRefreshToken.mockResolvedValue(null);
1939
-
1940
- try {
1941
- await service.refreshToken(mockRefreshToken);
1942
- fail('Should have thrown NAuthException');
1943
- } catch (error: any) {
1944
- expect(error).toBeInstanceOf(NAuthException);
1945
- expect(error.code).toBe(AuthErrorCode.SESSION_NOT_FOUND);
1946
- }
1947
- });
1948
-
1949
- it('should throw NAuthException if session is revoked', async () => {
1950
- const revokedSession = { ...mockSession, isRevoked: true };
1951
- mockSessionService.findByRefreshToken.mockResolvedValue(revokedSession);
1952
-
1953
- try {
1954
- await service.refreshToken(mockRefreshToken);
1955
- fail('Should have thrown NAuthException');
1956
- } catch (error: any) {
1957
- expect(error).toBeInstanceOf(NAuthException);
1958
- expect(error.code).toBe(AuthErrorCode.SESSION_NOT_FOUND);
1959
- }
1960
- });
1961
-
1962
- it('should throw NAuthException if session changed after lock acquisition', async () => {
1963
- mockSessionService.findByIdLight.mockResolvedValue(null);
1964
-
1965
- try {
1966
- await service.refreshToken(mockRefreshToken);
1967
- fail('Should have thrown NAuthException');
1968
- } catch (error: any) {
1969
- expect(error).toBeInstanceOf(NAuthException);
1970
- expect(error.code).toBe(AuthErrorCode.SESSION_NOT_FOUND);
1971
- }
1972
- });
1973
- });
1974
-
1975
- describe('Distributed locking', () => {
1976
- it('should throw NAuthException if lock cannot be acquired', async () => {
1977
- mockSessionService.acquireRefreshLock.mockResolvedValue(false);
1978
-
1979
- try {
1980
- await service.refreshToken(mockRefreshToken);
1981
- fail('Should have thrown NAuthException');
1982
- } catch (error: any) {
1983
- expect(error).toBeInstanceOf(NAuthException);
1984
- expect(error.code).toBe(AuthErrorCode.RATE_LIMIT_LOGIN);
1985
- }
1986
- });
1987
-
1988
- it('should release lock even if validation fails', async () => {
1989
- mockJwtService.validateRefreshToken.mockResolvedValue({
1990
- valid: false,
1991
- payload: undefined,
1992
- } as any);
1993
-
1994
- try {
1995
- await service.refreshToken(mockRefreshToken);
1996
- fail('Should have thrown NAuthException');
1997
- } catch (error: any) {
1998
- // Expected
1999
- }
2000
-
2001
- expect(mockSessionService.releaseRefreshLock).toHaveBeenCalled();
2002
- });
2003
-
2004
- it('should release lock even if token generation fails', async () => {
2005
- mockJwtService.generateTokenPair.mockRejectedValue(new Error('Token generation failed'));
2006
-
2007
- try {
2008
- await service.refreshToken(mockRefreshToken);
2009
- fail('Should have thrown Error');
2010
- } catch (error: any) {
2011
- // Expected
2012
- }
2013
-
2014
- expect(mockSessionService.releaseRefreshLock).toHaveBeenCalled();
2015
- });
2016
- });
2017
-
2018
- describe('Token reuse detection', () => {
2019
- it('should detect token reuse via atomic mark failure and audit the event', async () => {
2020
- mockConfig.jwt.refreshToken.reuseDetection = true;
2021
- // First check passes (token not yet marked)
2022
- mockSessionService.isRefreshTokenUsed.mockResolvedValue(false);
2023
- // But atomic mark fails (token was already used by another request)
2024
- mockSessionService.markRefreshTokenAsUsed.mockResolvedValue(false);
2025
-
2026
- try {
2027
- await service.refreshToken(mockRefreshToken);
2028
- fail('Should have thrown NAuthException');
2029
- } catch (error: any) {
2030
- expect(error).toBeInstanceOf(NAuthException);
2031
- expect(error.code).toBe(AuthErrorCode.TOKEN_INVALID);
2032
- }
2033
-
2034
- // Should audit the reuse attempt
2035
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
2036
- (expect as any).objectContaining({
2037
- eventType: AuthAuditEventType.SUSPICIOUS_ACTIVITY,
2038
- riskFactors: (expect as any).arrayContaining([RiskFactor.TOKEN_REUSE_ATTEMPT]),
2039
- }),
2040
- );
2041
- });
2042
-
2043
- it('should handle cookie race condition (same session, token already used)', async () => {
2044
- mockConfig.jwt.refreshToken.reuseDetection = true;
2045
- mockSessionService.isRefreshTokenUsed.mockResolvedValue(true);
2046
- // Token's sessionId matches the session we found (cookie race)
2047
- mockJwtService.decodeToken.mockReturnValue({
2048
- ...mockPayload,
2049
- sessionId: mockSession.id.toString(),
2050
- } as any);
2051
-
2052
- const result = await service.refreshToken(mockRefreshToken);
2053
-
2054
- // Should return current tokens (not throw error)
2055
- expect(result.accessToken).toBeDefined();
2056
- expect(result.refreshToken).toBeDefined();
2057
- });
2058
-
2059
- it('should detect attack when token reused from different session', async () => {
2060
- mockConfig.jwt.refreshToken.reuseDetection = true;
2061
- mockSessionService.isRefreshTokenUsed.mockResolvedValue(true);
2062
- // Token's sessionId doesn't match the session we found (attack!)
2063
- mockJwtService.decodeToken.mockReturnValue({
2064
- ...mockPayload,
2065
- sessionId: '999', // Different session ID
2066
- } as any);
2067
- mockSessionService.revokeSession.mockResolvedValue(undefined);
2068
-
2069
- try {
2070
- await service.refreshToken(mockRefreshToken);
2071
- fail('Should have thrown NAuthException');
2072
- } catch (error: any) {
2073
- expect(error).toBeInstanceOf(NAuthException);
2074
- expect(error.code).toBe(AuthErrorCode.TOKEN_INVALID);
2075
- }
2076
-
2077
- expect(mockSessionService.revokeSession).toHaveBeenCalledWith(
2078
- mockSession.id,
2079
- 'Token reuse detected - possible token theft',
2080
- );
2081
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
2082
- (expect as any).objectContaining({
2083
- eventType: AuthAuditEventType.SUSPICIOUS_ACTIVITY,
2084
- eventStatus: 'SUSPICIOUS',
2085
- riskFactors: (expect as any).arrayContaining([RiskFactor.TOKEN_THEFT_ATTEMPT]),
2086
- }),
2087
- );
2088
- });
2089
-
2090
- it('should throw NAuthException if atomic mark fails (reuse detected)', async () => {
2091
- mockConfig.jwt.refreshToken.reuseDetection = true;
2092
- mockSessionService.markRefreshTokenAsUsed.mockResolvedValue(false);
2093
-
2094
- try {
2095
- await service.refreshToken(mockRefreshToken);
2096
- fail('Should have thrown NAuthException');
2097
- } catch (error: any) {
2098
- expect(error).toBeInstanceOf(NAuthException);
2099
- expect(error.code).toBe(AuthErrorCode.TOKEN_INVALID);
2100
- }
2101
-
2102
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
2103
- (expect as any).objectContaining({
2104
- eventType: AuthAuditEventType.SUSPICIOUS_ACTIVITY,
2105
- riskFactors: (expect as any).arrayContaining([RiskFactor.TOKEN_REUSE_ATTEMPT]),
2106
- }),
2107
- );
2108
- });
2109
- });
2110
-
2111
- describe('Token family management', () => {
2112
- it('should use same token family for rotated tokens', async () => {
2113
- await service.refreshToken(mockRefreshToken);
2114
-
2115
- expect(mockJwtService.generateTokenPair).toHaveBeenCalledWith(
2116
- (expect as any).objectContaining({
2117
- tokenFamily: mockSession.tokenFamily,
2118
- }),
2119
- );
2120
- });
2121
-
2122
- it('should audit token reuse attempt when atomic mark fails', async () => {
2123
- mockConfig.jwt.refreshToken.reuseDetection = true;
2124
- mockSessionService.isRefreshTokenUsed.mockResolvedValue(false);
2125
- mockSessionService.markRefreshTokenAsUsed.mockResolvedValue(false);
2126
-
2127
- try {
2128
- await service.refreshToken(mockRefreshToken);
2129
- fail('Should have thrown NAuthException');
2130
- } catch (error: any) {
2131
- // Expected
2132
- }
2133
-
2134
- // Should audit the reuse attempt
2135
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
2136
- (expect as any).objectContaining({
2137
- eventType: AuthAuditEventType.SUSPICIOUS_ACTIVITY,
2138
- riskFactors: (expect as any).arrayContaining([RiskFactor.TOKEN_REUSE_ATTEMPT]),
2139
- }),
2140
- );
2141
- });
2142
- });
2143
-
2144
- describe('Error handling', () => {
2145
- it('should handle user not found in cookie race scenario', async () => {
2146
- // Cookie race scenario - token already used for same session
2147
- mockConfig.jwt.refreshToken.reuseDetection = true;
2148
- mockSessionService.isRefreshTokenUsed.mockResolvedValue(true);
2149
- mockJwtService.decodeToken.mockReturnValue({
2150
- ...mockPayload,
2151
- sessionId: mockSession.id.toString(),
2152
- } as any);
2153
- mockUserRepository.findOne.mockResolvedValue(null);
2154
-
2155
- try {
2156
- await service.refreshToken(mockRefreshToken);
2157
- fail('Should have thrown NAuthException');
2158
- } catch (error: any) {
2159
- expect(error).toBeInstanceOf(NAuthException);
2160
- expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
2161
- }
2162
- });
2163
-
2164
- it('should handle audit logging errors gracefully in cookie race scenario', async () => {
2165
- // Cookie race scenario where audit fails
2166
- mockConfig.jwt.refreshToken.reuseDetection = true;
2167
- mockSessionService.isRefreshTokenUsed.mockResolvedValue(true);
2168
- mockJwtService.decodeToken.mockReturnValue({
2169
- ...mockPayload,
2170
- sessionId: mockSession.id.toString(),
2171
- } as any);
2172
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
2173
-
2174
- const result = await service.refreshToken(mockRefreshToken);
2175
-
2176
- // Should still succeed despite audit error
2177
- expect(result.accessToken).toBeDefined();
2178
- });
2179
- });
2180
-
2181
- describe('Edge cases', () => {
2182
- it('should handle missing expiry in token payload', async () => {
2183
- mockJwtService.validateAccessToken.mockResolvedValue({
2184
- valid: true,
2185
- payload: {},
2186
- } as any);
2187
- mockJwtService.validateRefreshToken.mockResolvedValue({
2188
- valid: true,
2189
- payload: {},
2190
- } as any);
2191
-
2192
- const result = await service.refreshToken(mockRefreshToken);
2193
-
2194
- expect(result.accessTokenExpiresAt).toBe(0);
2195
- expect(result.refreshTokenExpiresAt).toBe(0);
2196
- });
2197
-
2198
- it('should handle token validation returning undefined payload', async () => {
2199
- mockJwtService.validateRefreshToken.mockResolvedValue({
2200
- valid: false,
2201
- payload: undefined,
2202
- } as any);
2203
-
2204
- try {
2205
- await service.refreshToken(mockRefreshToken);
2206
- fail('Should have thrown NAuthException');
2207
- } catch (error: any) {
2208
- expect(error).toBeInstanceOf(NAuthException);
2209
- }
2210
- });
2211
- });
2212
- });
2213
-
2214
- // ============================================================================
2215
- // logout Tests
2216
- // ============================================================================
2217
-
2218
- describe('logout()', () => {
2219
- const mockSub = 'user-123';
2220
- const mockSessionId = '1';
2221
-
2222
- beforeEach(() => {
2223
- mockSessionService.revokeSession.mockResolvedValue(undefined);
2224
- mockSessionService.findById.mockResolvedValue(mockSession);
2225
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
2226
- });
2227
-
2228
- describe('Successful logout', () => {
2229
- it('should revoke session on logout', async () => {
2230
- await service.logout(mockSub, mockSessionId);
2231
-
2232
- expect(mockSessionService.revokeSession).toHaveBeenCalledWith(mockSessionId, 'User logout', undefined);
2233
- });
2234
-
2235
- it('should revoke session with audit metadata', async () => {
2236
- await service.logout(mockSub, mockSessionId, false);
2237
-
2238
- expect(mockSessionService.revokeSession).toHaveBeenCalledWith(mockSessionId, 'User logout', undefined);
2239
- });
2240
-
2241
- it('should complete logout successfully', async () => {
2242
- await service.logout(mockSub, mockSessionId);
2243
-
2244
- expect(mockSessionService.revokeSession).toHaveBeenCalled();
2245
- });
2246
- });
2247
-
2248
- describe('Forget device (forgetMe)', () => {
2249
- it('should revoke trusted device when forgetMe is true', async () => {
2250
- mockConfig.mfa = {
2251
- rememberDevices: 'always',
2252
- rememberDeviceDays: 30,
2253
- };
2254
- mockClientInfo.deviceToken = 'device-token-123';
2255
-
2256
- await service.logout(mockSub, mockSessionId, true);
2257
-
2258
- expect(mockSessionService.revokeSession).toHaveBeenCalledWith(
2259
- mockSessionId,
2260
- 'User logout',
2261
- (expect as any).objectContaining({
2262
- deviceForgotten: true,
2263
- reason: 'User requested device to be forgotten on logout',
2264
- }),
2265
- );
2266
- expect(mockSessionService.findById).toHaveBeenCalledWith(parseInt(mockSessionId, 10));
2267
- expect(mockTrustedDeviceService.revokeTrustedDevice).toHaveBeenCalledWith(
2268
- mockClientInfo.deviceToken,
2269
- mockSession.userId,
2270
- );
2271
- });
2272
-
2273
- it('should not revoke trusted device when forgetMe is false', async () => {
2274
- await service.logout(mockSub, mockSessionId, false);
2275
-
2276
- expect(mockTrustedDeviceService.revokeTrustedDevice).not.toHaveBeenCalled();
2277
- });
2278
-
2279
- it('should handle missing deviceToken gracefully when forgetMe is true', async () => {
2280
- mockConfig.mfa = {
2281
- rememberDevices: 'always',
2282
- rememberDeviceDays: 30,
2283
- };
2284
- mockClientInfo.deviceToken = undefined;
2285
-
2286
- await service.logout(mockSub, mockSessionId, true);
2287
-
2288
- // Should still revoke session, but not call revokeTrustedDevice
2289
- expect(mockSessionService.revokeSession).toHaveBeenCalled();
2290
- expect(mockTrustedDeviceService.revokeTrustedDevice).not.toHaveBeenCalled();
2291
- });
2292
-
2293
- it('should record DEVICE_UNTRUSTED audit event when forgetMe is true', async () => {
2294
- mockConfig.mfa = {
2295
- rememberDevices: 'always',
2296
- rememberDeviceDays: 30,
2297
- };
2298
- mockClientInfo.deviceToken = 'device-token-123';
2299
-
2300
- await service.logout(mockSub, mockSessionId, true);
2301
-
2302
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
2303
- (expect as any).objectContaining({
2304
- eventType: AuthAuditEventType.DEVICE_UNTRUSTED,
2305
- eventStatus: 'SUCCESS',
2306
- }),
2307
- );
2308
- });
2309
- });
2310
-
2311
- describe('Error handling', () => {
2312
- it('should handle session revocation errors gracefully', async () => {
2313
- mockSessionService.revokeSession.mockRejectedValue(new Error('Session not found'));
2314
-
2315
- try {
2316
- await service.logout(mockSub, mockSessionId);
2317
- fail('Should have thrown Error');
2318
- } catch (error: any) {
2319
- expect(error).toBeInstanceOf(Error);
2320
- }
2321
- });
2322
-
2323
- it('should handle trusted device removal errors gracefully', async () => {
2324
- mockConfig.mfa = {
2325
- rememberDevices: 'always',
2326
- rememberDeviceDays: 30,
2327
- };
2328
- mockClientInfo.deviceToken = 'device-token-123';
2329
- (mockTrustedDeviceService.revokeTrustedDevice as jest.Mock).mockRejectedValue(new Error('Device not found'));
2330
-
2331
- // Should still complete logout even if device removal fails
2332
- await service.logout(mockSub, mockSessionId, true);
2333
-
2334
- expect(mockSessionService.revokeSession).toHaveBeenCalled();
2335
- expect(mockLogger.debug).toHaveBeenCalled();
2336
- });
2337
-
2338
- it('should handle audit logging errors gracefully when forgetMe is true', async () => {
2339
- mockConfig.mfa = {
2340
- rememberDevices: 'always',
2341
- rememberDeviceDays: 30,
2342
- };
2343
- mockClientInfo.deviceToken = 'device-token-123';
2344
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
2345
-
2346
- await service.logout(mockSub, mockSessionId, true);
2347
-
2348
- // Should still complete logout despite audit error
2349
- expect(mockSessionService.revokeSession).toHaveBeenCalled();
2350
- expect(mockLogger.error).toHaveBeenCalled();
2351
- });
2352
- });
2353
- });
2354
-
2355
- // ============================================================================
2356
- // logoutAll Tests
2357
- // ============================================================================
2358
-
2359
- describe('logoutAll()', () => {
2360
- const mockSub = 'user-123';
2361
- const mockUserId = 1;
2362
-
2363
- beforeEach(() => {
2364
- mockUserRepository.findOne.mockResolvedValue({ ...mockUser, id: mockUserId } as any);
2365
- mockSessionService.revokeAllUserSessions.mockResolvedValue(5);
2366
- });
2367
-
2368
- describe('Successful logout all', () => {
2369
- it('should revoke all user sessions', async () => {
2370
- await service.logoutAll(mockSub);
2371
-
2372
- expect(mockUserRepository.findOne).toHaveBeenCalledWith({
2373
- where: { sub: mockSub } as any,
2374
- });
2375
- expect(mockSessionService.revokeAllUserSessions).toHaveBeenCalledWith(mockUserId, 'Global signout');
2376
- });
2377
-
2378
- it('should return number of revoked sessions', async () => {
2379
- const result = await service.logoutAll(mockSub);
2380
-
2381
- expect(result).toBe(5);
2382
- });
2383
-
2384
- it('should complete logoutAll successfully', async () => {
2385
- const result = await service.logoutAll(mockSub);
2386
-
2387
- expect(result).toBe(5);
2388
- expect(mockSessionService.revokeAllUserSessions).toHaveBeenCalled();
2389
- });
2390
- });
2391
-
2392
- describe('User not found', () => {
2393
- it('should throw NAuthException if user not found', async () => {
2394
- mockUserRepository.findOne.mockResolvedValue(null);
2395
-
2396
- try {
2397
- await service.logoutAll(mockSub);
2398
- fail('Should have thrown NAuthException');
2399
- } catch (error: any) {
2400
- expect(error).toBeInstanceOf(NAuthException);
2401
- expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
2402
- }
2403
- });
2404
- });
2405
-
2406
- describe('Error handling', () => {
2407
- it('should handle session revocation errors gracefully', async () => {
2408
- mockSessionService.revokeAllUserSessions.mockRejectedValue(new Error('Database error'));
2409
-
2410
- try {
2411
- await service.logoutAll(mockSub);
2412
- fail('Should have thrown Error');
2413
- } catch (error: any) {
2414
- expect(error).toBeInstanceOf(Error);
2415
- }
2416
- });
2417
-
2418
- it('should complete logoutAll even if errors occur', async () => {
2419
- // logoutAll doesn't directly record audit events, so this test just verifies it completes
2420
- const result = await service.logoutAll(mockSub);
2421
-
2422
- expect(result).toBe(5);
2423
- });
2424
- });
2425
- });
2426
-
2427
- // ============================================================================
2428
- // changePassword Tests
2429
- // ============================================================================
2430
-
2431
- describe('changePassword()', () => {
2432
- const changePasswordDto = {
2433
- oldPassword: 'OldPassword123!',
2434
- newPassword: 'NewPassword456!',
2435
- };
2436
-
2437
- beforeEach(() => {
2438
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
2439
- mockPasswordService.verifyPassword.mockResolvedValue(true);
2440
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true } as any);
2441
- mockPasswordService.isPasswordInHistory.mockResolvedValue(false);
2442
- mockPasswordService.hashPassword.mockResolvedValue('new-hashed-password');
2443
- mockPasswordService.addToHistory.mockReturnValue([]);
2444
- mockUserRepository.save.mockResolvedValue(mockUser as any);
2445
- });
2446
-
2447
- describe('Successful password change', () => {
2448
- it('should change password successfully', async () => {
2449
- await service.changePassword(mockUser.sub, changePasswordDto);
2450
-
2451
- expect(mockPasswordService.verifyPassword).toHaveBeenCalledWith(
2452
- changePasswordDto.oldPassword,
2453
- mockUser.passwordHash!,
2454
- );
2455
- expect(mockPasswordService.validatePassword).toHaveBeenCalledWith(changePasswordDto.newPassword, {
2456
- email: mockUser.email,
2457
- username: mockUser.username || undefined,
2458
- });
2459
- expect(mockPasswordService.hashPassword).toHaveBeenCalledWith(changePasswordDto.newPassword);
2460
- expect(mockUserRepository.save).toHaveBeenCalled();
2461
- });
2462
-
2463
- it('should update password hash in database', async () => {
2464
- await service.changePassword(mockUser.sub, changePasswordDto);
2465
-
2466
- expect(mockUserRepository.save).toHaveBeenCalledWith(
2467
- (expect as any).objectContaining({
2468
- passwordHash: 'new-hashed-password',
2469
- passwordChangedAt: (expect as any).any(Date),
2470
- passwordHistory: (expect as any).any(Array),
2471
- }),
2472
- );
2473
- });
2474
-
2475
- it('should add old password to history', async () => {
2476
- await service.changePassword(mockUser.sub, changePasswordDto);
2477
-
2478
- expect(mockPasswordService.addToHistory).toHaveBeenCalledWith(
2479
- mockUser.passwordHistory || [],
2480
- mockUser.passwordHash!,
2481
- );
2482
- expect(mockSessionService.revokeAllUserSessions).toHaveBeenCalledWith(mockUser.id, 'Password changed');
2483
- });
2484
-
2485
- it('should revoke all sessions after password change', async () => {
2486
- await service.changePassword(mockUser.sub, changePasswordDto);
2487
-
2488
- expect(mockSessionService.revokeAllUserSessions).toHaveBeenCalledWith(mockUser.id, 'Password changed');
2489
- });
2490
-
2491
- it('should record PASSWORD_CHANGED audit event', async () => {
2492
- await service.changePassword(mockUser.sub, changePasswordDto);
2493
-
2494
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
2495
- (expect as any).objectContaining({
2496
- userId: mockUser.id,
2497
- eventType: AuthAuditEventType.PASSWORD_CHANGED,
2498
- eventStatus: 'SUCCESS',
2499
- }),
2500
- );
2501
- });
2502
- });
2503
-
2504
- describe('Password validation', () => {
2505
- it('should throw NAuthException if current password is incorrect', async () => {
2506
- mockPasswordService.verifyPassword.mockResolvedValue(false);
2507
-
2508
- try {
2509
- await service.changePassword(mockUser.sub, changePasswordDto);
2510
- fail('Should have thrown NAuthException');
2511
- } catch (error: any) {
2512
- expect(error).toBeInstanceOf(NAuthException);
2513
- expect(error.code).toBe(AuthErrorCode.PASSWORD_INCORRECT);
2514
- }
2515
- });
2516
-
2517
- it('should throw NAuthException if new password is invalid', async () => {
2518
- mockPasswordService.validatePassword.mockResolvedValue({
2519
- valid: false,
2520
- errors: ['Password too weak'],
2521
- } as any);
2522
-
2523
- try {
2524
- await service.changePassword(mockUser.sub, changePasswordDto);
2525
- fail('Should have thrown NAuthException');
2526
- } catch (error: any) {
2527
- expect(error).toBeInstanceOf(NAuthException);
2528
- // Password validation happens in handleForceChangePassword, which throws WEAK_PASSWORD
2529
- // But validation might happen earlier in validateChallengeParams
2530
- expect([AuthErrorCode.WEAK_PASSWORD, AuthErrorCode.VALIDATION_FAILED]).toContain(error.code);
2531
- }
2532
- });
2533
-
2534
- it('should allow password change even if hash matches (service does not prevent same password)', async () => {
2535
- mockPasswordService.verifyPassword.mockResolvedValue(true);
2536
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true } as any);
2537
- // Simulate same password by making hash match - service doesn't prevent this
2538
- mockPasswordService.hashPassword.mockResolvedValue(mockUser.passwordHash!);
2539
-
2540
- // Service doesn't check if new hash equals old hash, so this should succeed
2541
- await service.changePassword(mockUser.sub, changePasswordDto);
2542
-
2543
- expect(mockUserRepository.update).toHaveBeenCalled();
2544
- });
2545
-
2546
- it('should throw NAuthException if new password is in history', async () => {
2547
- mockPasswordService.isPasswordInHistory.mockResolvedValue(true);
2548
-
2549
- try {
2550
- await service.changePassword(mockUser.sub, changePasswordDto);
2551
- fail('Should have thrown NAuthException');
2552
- } catch (error: any) {
2553
- expect(error).toBeInstanceOf(NAuthException);
2554
- expect(error.code).toBe(AuthErrorCode.PASSWORD_REUSED);
2555
- }
2556
- });
2557
- });
2558
-
2559
- describe('User not found', () => {
2560
- it('should throw NAuthException if user not found', async () => {
2561
- mockUserRepository.findOne.mockResolvedValue(null);
2562
-
2563
- try {
2564
- await service.changePassword(mockUser.sub, changePasswordDto);
2565
- fail('Should have thrown NAuthException');
2566
- } catch (error: any) {
2567
- expect(error).toBeInstanceOf(NAuthException);
2568
- expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
2569
- }
2570
- });
2571
- });
2572
-
2573
- describe('Social-only users', () => {
2574
- it('should throw NAuthException if user has no password (social-only)', async () => {
2575
- const socialUser = { ...mockUser, passwordHash: null };
2576
- mockUserRepository.findOne.mockResolvedValue(socialUser as any);
2577
-
2578
- try {
2579
- await service.changePassword(mockUser.sub, changePasswordDto);
2580
- fail('Should have thrown NAuthException');
2581
- } catch (error: any) {
2582
- expect(error).toBeInstanceOf(NAuthException);
2583
- expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
2584
- }
2585
- });
2586
- });
2587
-
2588
- describe('Password history management', () => {
2589
- it('should check password history when historyCount is configured', async () => {
2590
- mockConfig.password!.historyCount = 10;
2591
- const userWithHistory = { ...mockUser, passwordHistory: ['hash1', 'hash2'] };
2592
- mockUserRepository.findOne.mockResolvedValue(userWithHistory as any);
2593
-
2594
- await service.changePassword(mockUser.sub, changePasswordDto);
2595
-
2596
- expect(mockPasswordService.isPasswordInHistory).toHaveBeenCalledWith(
2597
- changePasswordDto.newPassword,
2598
- userWithHistory.passwordHistory,
2599
- );
2600
- });
2601
-
2602
- it('should handle empty password history', async () => {
2603
- const userWithNoHistory = { ...mockUser, passwordHistory: [] };
2604
- mockUserRepository.findOne.mockResolvedValue(userWithNoHistory as any);
2605
-
2606
- await service.changePassword(mockUser.sub, changePasswordDto);
2607
-
2608
- expect(mockPasswordService.addToHistory).toHaveBeenCalledWith([], mockUser.passwordHash!);
2609
- });
2610
- });
2611
-
2612
- describe('Error handling', () => {
2613
- it('should handle database update errors gracefully', async () => {
2614
- mockUserRepository.update.mockRejectedValue(new Error('Database error'));
2615
-
2616
- try {
2617
- await service.changePassword(mockUser.sub, changePasswordDto);
2618
- fail('Should have thrown Error');
2619
- } catch (error: any) {
2620
- expect(error).toBeInstanceOf(Error);
2621
- }
2622
- });
2623
-
2624
- it('should handle audit logging errors gracefully', async () => {
2625
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
2626
-
2627
- await service.changePassword(mockUser.sub, changePasswordDto);
2628
-
2629
- // Should still complete password change despite audit error
2630
- expect(mockUserRepository.update).toHaveBeenCalled();
2631
- expect(mockLogger.error).toHaveBeenCalled();
2632
- });
2633
- });
2634
-
2635
- describe('Edge cases', () => {
2636
- it('should handle null passwordHash gracefully', async () => {
2637
- const userWithNullHash = { ...mockUser, passwordHash: null };
2638
- mockUserRepository.findOne.mockResolvedValue(userWithNullHash as any);
2639
-
2640
- try {
2641
- await service.changePassword(mockUser.sub, changePasswordDto);
2642
- fail('Should have thrown NAuthException');
2643
- } catch (error: any) {
2644
- expect(error).toBeInstanceOf(NAuthException);
2645
- }
2646
- });
2647
-
2648
- it('should handle missing password history gracefully', async () => {
2649
- const userWithNoHistory = { ...mockUser, passwordHistory: null };
2650
- mockUserRepository.findOne.mockResolvedValue(userWithNoHistory as any);
2651
-
2652
- await service.changePassword(mockUser.sub, changePasswordDto);
2653
-
2654
- expect(mockPasswordService.addToHistory).toHaveBeenCalledWith([], mockUser.passwordHash!);
2655
- });
2656
- });
2657
- });
2658
-
2659
- // ============================================================================
2660
- // updateUserAttributes Tests
2661
- // ============================================================================
2662
-
2663
- describe('updateUserAttributes()', () => {
2664
- const updateData = {
2665
- firstName: 'Updated',
2666
- lastName: 'Name',
2667
- email: 'updated@example.com',
2668
- };
2669
-
2670
- beforeEach(() => {
2671
- // Setup default mock chain: initial lookup, uniqueness checks, final fetch
2672
- mockUserRepository.findOne
2673
- .mockResolvedValueOnce(mockUser as any) // Initial user lookup
2674
- .mockResolvedValueOnce(null) // Email uniqueness check (if email in updateData)
2675
- .mockResolvedValueOnce(null) // Phone uniqueness check (if phone in updateData)
2676
- .mockResolvedValueOnce(null) // Username uniqueness check (if username in updateData)
2677
- .mockResolvedValueOnce({ ...mockUser, ...updateData } as any); // Final fetch after update (by id)
2678
- mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
2679
- });
2680
-
2681
- describe('Successful updates', () => {
2682
- it('should update user attributes successfully', async () => {
2683
- // Reset and setup mocks for this test
2684
- mockUserRepository.findOne.mockReset();
2685
- mockUserRepository.findOne
2686
- .mockResolvedValueOnce(mockUser as any) // Initial lookup by sub
2687
- .mockResolvedValueOnce(null) // Email uniqueness check
2688
- .mockResolvedValueOnce({ ...mockUser, ...updateData } as any); // Final fetch by id
2689
-
2690
- const result = await service.updateUserAttributes(mockUser.sub, updateData);
2691
-
2692
- expect(mockUserRepository.findOne).toHaveBeenCalledWith({
2693
- where: { sub: mockUser.sub } as any,
2694
- });
2695
- expect(mockUserRepository.update).toHaveBeenCalled();
2696
- expect(result).toBeDefined();
2697
- });
2698
-
2699
- it('should update firstName and lastName', async () => {
2700
- mockUserRepository.findOne.mockReset();
2701
- mockUserRepository.findOne
2702
- .mockResolvedValueOnce(mockUser as any)
2703
- .mockResolvedValueOnce({ ...mockUser, firstName: 'John', lastName: 'Doe' } as any);
2704
-
2705
- await service.updateUserAttributes(mockUser.sub, {
2706
- firstName: 'John',
2707
- lastName: 'Doe',
2708
- });
2709
-
2710
- expect(mockUserRepository.update).toHaveBeenCalledWith(
2711
- mockUser.id,
2712
- (expect as any).objectContaining({
2713
- firstName: 'John',
2714
- lastName: 'Doe',
2715
- }),
2716
- );
2717
- });
2718
-
2719
- it('should update username', async () => {
2720
- mockUserRepository.findOne.mockReset();
2721
- mockUserRepository.findOne
2722
- .mockResolvedValueOnce(mockUser as any)
2723
- .mockResolvedValueOnce(null) // Username uniqueness check
2724
- .mockResolvedValueOnce({ ...mockUser, username: 'newusername' } as any);
2725
-
2726
- await service.updateUserAttributes(mockUser.sub, {
2727
- username: 'newusername',
2728
- });
2729
-
2730
- expect(mockUserRepository.update).toHaveBeenCalledWith(
2731
- mockUser.id,
2732
- (expect as any).objectContaining({
2733
- username: 'newusername',
2734
- }),
2735
- );
2736
- });
2737
-
2738
- it('should update email and reset verification status', async () => {
2739
- mockUserRepository.findOne.mockReset();
2740
- mockUserRepository.findOne
2741
- .mockResolvedValueOnce(mockUser as any)
2742
- .mockResolvedValueOnce(null) // Email uniqueness check
2743
- .mockResolvedValueOnce({ ...mockUser, email: 'newemail@example.com', isEmailVerified: false } as any);
2744
-
2745
- await service.updateUserAttributes(mockUser.sub, {
2746
- email: 'newemail@example.com',
2747
- });
2748
-
2749
- expect(mockUserRepository.update).toHaveBeenCalledWith(
2750
- mockUser.id,
2751
- (expect as any).objectContaining({
2752
- email: 'newemail@example.com',
2753
- isEmailVerified: false,
2754
- }),
2755
- );
2756
- });
2757
-
2758
- it('should update phone and reset verification status', async () => {
2759
- mockUserRepository.findOne.mockReset();
2760
- mockUserRepository.findOne
2761
- .mockResolvedValueOnce(mockUser as any)
2762
- .mockResolvedValueOnce(null) // Phone uniqueness check
2763
- .mockResolvedValueOnce({ ...mockUser, phone: '+1987654321', isPhoneVerified: false } as any);
2764
- mockMfaDeviceRepository.find.mockResolvedValue([]);
2765
-
2766
- await service.updateUserAttributes(mockUser.sub, {
2767
- phone: '+1987654321',
2768
- });
2769
-
2770
- expect(mockUserRepository.update).toHaveBeenCalledWith(
2771
- mockUser.id,
2772
- (expect as any).objectContaining({
2773
- phone: '+1987654321',
2774
- isPhoneVerified: false,
2775
- }),
2776
- );
2777
- });
2778
-
2779
- it('should retain verification status when retainVerification is true', async () => {
2780
- const verifiedUser = { ...mockUser, isEmailVerified: true, isPhoneVerified: true };
2781
- mockUserRepository.findOne.mockReset();
2782
- mockUserRepository.findOne
2783
- .mockResolvedValueOnce(verifiedUser as any) // Initial lookup by sub
2784
- .mockResolvedValueOnce(null) // Email uniqueness check
2785
- .mockResolvedValueOnce(null) // Phone uniqueness check
2786
- .mockResolvedValueOnce({ ...verifiedUser, email: 'newemail@example.com', phone: '+1987654321' } as any); // Final fetch by id
2787
- mockMfaDeviceRepository.find.mockResolvedValue([]);
2788
-
2789
- await service.updateUserAttributes(mockUser.sub, {
2790
- email: 'newemail@example.com',
2791
- phone: '+1987654321',
2792
- retainVerification: true,
2793
- });
2794
-
2795
- expect(mockUserRepository.update).toHaveBeenCalledWith(
2796
- mockUser.id,
2797
- (expect as any).objectContaining({
2798
- email: 'newemail@example.com',
2799
- phone: '+1987654321',
2800
- isEmailVerified: true,
2801
- isPhoneVerified: true,
2802
- }),
2803
- );
2804
- });
2805
-
2806
- it('should preserve unverified status when retainVerification is true', async () => {
2807
- const unverifiedUser = { ...mockUser, isEmailVerified: false, isPhoneVerified: false };
2808
- mockUserRepository.findOne.mockReset();
2809
- mockUserRepository.findOne
2810
- .mockResolvedValueOnce(unverifiedUser as any) // Initial lookup by sub
2811
- .mockResolvedValueOnce(null) // Email uniqueness check
2812
- .mockResolvedValueOnce(null) // Phone uniqueness check
2813
- .mockResolvedValueOnce({ ...unverifiedUser, email: 'newemail@example.com', phone: '+1987654321' } as any); // Final fetch by id
2814
- mockMfaDeviceRepository.find.mockResolvedValue([]);
2815
-
2816
- await service.updateUserAttributes(mockUser.sub, {
2817
- email: 'newemail@example.com',
2818
- phone: '+1987654321',
2819
- retainVerification: true,
2820
- });
2821
-
2822
- expect(mockUserRepository.update).toHaveBeenCalledWith(
2823
- mockUser.id,
2824
- (expect as any).objectContaining({
2825
- email: 'newemail@example.com',
2826
- phone: '+1987654321',
2827
- isEmailVerified: false,
2828
- isPhoneVerified: false,
2829
- }),
2830
- );
2831
- });
2832
-
2833
- it('should merge metadata when updating', async () => {
2834
- const userWithMetadata = { ...mockUser, metadata: { key1: 'value1' } };
2835
- mockUserRepository.findOne.mockReset();
2836
- mockUserRepository.findOne
2837
- .mockResolvedValueOnce(userWithMetadata as any) // Initial lookup by sub
2838
- .mockResolvedValueOnce({ ...userWithMetadata, metadata: { key1: 'value1', key2: 'value2' } } as any); // Final fetch by id
2839
-
2840
- await service.updateUserAttributes(mockUser.sub, {
2841
- metadata: { key2: 'value2' },
2842
- });
2843
-
2844
- expect(mockUserRepository.update).toHaveBeenCalledWith(
2845
- mockUser.id,
2846
- (expect as any).objectContaining({
2847
- metadata: (expect as any).objectContaining({
2848
- key1: 'value1',
2849
- key2: 'value2',
2850
- }),
2851
- }),
2852
- );
2853
- });
2854
-
2855
- it('should record PROFILE_UPDATED audit event', async () => {
2856
- mockUserRepository.findOne.mockReset();
2857
- mockUserRepository.findOne
2858
- .mockResolvedValueOnce(mockUser as any)
2859
- .mockResolvedValueOnce(null) // Email uniqueness check
2860
- .mockResolvedValueOnce({ ...mockUser, ...updateData } as any);
2861
-
2862
- await service.updateUserAttributes(mockUser.sub, updateData);
2863
-
2864
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
2865
- (expect as any).objectContaining({
2866
- userId: mockUser.id,
2867
- eventType: AuthAuditEventType.PROFILE_UPDATED,
2868
- eventStatus: 'INFO',
2869
- }),
2870
- );
2871
- });
2872
- });
2873
-
2874
- describe('MFA device management', () => {
2875
- it('should delete Email MFA devices when email changes', async () => {
2876
- const userWithEmail = { ...mockUser, email: 'old@example.com' };
2877
- const emailDevice = {
2878
- id: 1,
2879
- userId: mockUser.id,
2880
- type: MFAMethod.EMAIL,
2881
- isActive: true,
2882
- };
2883
-
2884
- mockUserRepository.findOne.mockReset();
2885
- mockUserRepository.findOne
2886
- .mockResolvedValueOnce(userWithEmail as any) // Initial lookup
2887
- .mockResolvedValueOnce(null) // Email uniqueness check
2888
- .mockResolvedValueOnce({ ...userWithEmail, email: 'new@example.com' } as any); // Final fetch
2889
-
2890
- mockMfaDeviceRepository.find
2891
- .mockResolvedValueOnce([emailDevice] as any) // Find Email devices
2892
- .mockResolvedValueOnce([emailDevice] as any); // Check remaining devices
2893
-
2894
- mockMfaDeviceRepository.delete.mockResolvedValue({ affected: 1 } as any);
2895
-
2896
- await service.updateUserAttributes(mockUser.sub, {
2897
- email: 'new@example.com',
2898
- });
2899
-
2900
- expect(mockMfaDeviceRepository.delete).toHaveBeenCalledWith(1);
2901
- });
2902
-
2903
- it('should record audit event when Email MFA devices are deleted', async () => {
2904
- const userWithEmail = { ...mockUser, email: 'old@example.com' };
2905
- const emailDevice = {
2906
- id: 1,
2907
- userId: mockUser.id,
2908
- type: MFAMethod.EMAIL,
2909
- isActive: true,
2910
- };
2911
-
2912
- mockUserRepository.findOne.mockReset();
2913
- mockUserRepository.findOne
2914
- .mockResolvedValueOnce(userWithEmail as any)
2915
- .mockResolvedValueOnce(null)
2916
- .mockResolvedValueOnce({ ...userWithEmail, email: 'new@example.com' } as any);
2917
-
2918
- mockMfaDeviceRepository.find
2919
- .mockResolvedValueOnce([emailDevice] as any)
2920
- .mockResolvedValueOnce([emailDevice] as any);
2921
-
2922
- mockMfaDeviceRepository.delete.mockResolvedValue({ affected: 1 } as any);
2923
-
2924
- await service.updateUserAttributes(mockUser.sub, {
2925
- email: 'new@example.com',
2926
- });
2927
-
2928
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
2929
- (expect as any).objectContaining({
2930
- userId: mockUser.id,
2931
- eventType: AuthAuditEventType.MFA_DEVICE_REMOVED,
2932
- eventStatus: 'INFO',
2933
- reason: 'email_changed',
2934
- metadata: (expect as any).objectContaining({
2935
- method: MFAMethod.EMAIL,
2936
- deletedCount: 1,
2937
- oldEmail: 'old@example.com',
2938
- newEmail: 'new@example.com',
2939
- }),
2940
- }),
2941
- );
2942
- });
2943
-
2944
- it('should delete SMS MFA devices when phone changes', async () => {
2945
- const userWithPhone = { ...mockUser, phone: '+1234567890' };
2946
- const smsDevice = {
2947
- id: 1,
2948
- userId: mockUser.id,
2949
- type: MFAMethod.SMS,
2950
- phoneNumber: '+1234567890',
2951
- isActive: true,
2952
- };
2953
-
2954
- mockUserRepository.findOne.mockReset();
2955
- mockUserRepository.findOne
2956
- .mockResolvedValueOnce(userWithPhone as any) // Initial lookup
2957
- .mockResolvedValueOnce(null) // Phone uniqueness check
2958
- .mockResolvedValueOnce({ ...userWithPhone, phone: '+1987654321' } as any); // Final fetch
2959
-
2960
- mockMfaDeviceRepository.find
2961
- .mockResolvedValueOnce([smsDevice] as any) // Find SMS devices
2962
- .mockResolvedValueOnce([smsDevice] as any); // Check remaining devices
2963
-
2964
- mockMfaDeviceRepository.delete.mockResolvedValue({ affected: 1 } as any);
2965
-
2966
- await service.updateUserAttributes(mockUser.sub, {
2967
- phone: '+1987654321',
2968
- });
2969
-
2970
- expect(mockMfaDeviceRepository.delete).toHaveBeenCalledWith(1);
2971
- });
2972
-
2973
- it('should record audit event when SMS MFA devices are deleted', async () => {
2974
- const userWithPhone = { ...mockUser, phone: '+1234567890' };
2975
- const smsDevice = {
2976
- id: 1,
2977
- userId: mockUser.id,
2978
- type: MFAMethod.SMS,
2979
- phoneNumber: '+1234567890',
2980
- isActive: true,
2981
- };
2982
-
2983
- mockUserRepository.findOne.mockReset();
2984
- mockUserRepository.findOne
2985
- .mockResolvedValueOnce(userWithPhone as any)
2986
- .mockResolvedValueOnce(null)
2987
- .mockResolvedValueOnce({ ...userWithPhone, phone: '+1987654321' } as any);
2988
-
2989
- mockMfaDeviceRepository.find
2990
- .mockResolvedValueOnce([smsDevice] as any)
2991
- .mockResolvedValueOnce([smsDevice] as any);
2992
-
2993
- mockMfaDeviceRepository.delete.mockResolvedValue({ affected: 1 } as any);
2994
-
2995
- await service.updateUserAttributes(mockUser.sub, {
2996
- phone: '+1987654321',
2997
- });
2998
-
2999
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
3000
- (expect as any).objectContaining({
3001
- userId: mockUser.id,
3002
- eventType: AuthAuditEventType.MFA_DEVICE_REMOVED,
3003
- eventStatus: 'INFO',
3004
- reason: 'phone_changed',
3005
- metadata: (expect as any).objectContaining({
3006
- method: MFAMethod.SMS,
3007
- deletedCount: 1,
3008
- oldPhone: '+1234567890',
3009
- newPhone: '+1987654321',
3010
- }),
3011
- }),
3012
- );
3013
- });
3014
-
3015
- it('should disable MFA when all devices are removed after email change', async () => {
3016
- const userWithMfa = { ...mockUser, email: 'old@example.com', mfaEnabled: true };
3017
- const emailDevice = {
3018
- id: 1,
3019
- userId: mockUser.id,
3020
- type: MFAMethod.EMAIL,
3021
- isActive: true,
3022
- };
3023
-
3024
- mockUserRepository.findOne.mockReset();
3025
- mockUserRepository.findOne
3026
- .mockResolvedValueOnce(userWithMfa as any)
3027
- .mockResolvedValueOnce(null)
3028
- .mockResolvedValueOnce({ ...userWithMfa, email: 'new@example.com', mfaEnabled: false } as any);
3029
-
3030
- mockMfaDeviceRepository.find
3031
- .mockResolvedValueOnce([emailDevice] as any) // Find Email devices
3032
- .mockResolvedValueOnce([] as any); // No remaining devices
3033
-
3034
- mockMfaDeviceRepository.delete.mockResolvedValue({ affected: 1 } as any);
3035
-
3036
- await service.updateUserAttributes(mockUser.sub, {
3037
- email: 'new@example.com',
3038
- });
3039
-
3040
- expect(mockUserRepository.update).toHaveBeenCalledWith(
3041
- mockUser.id,
3042
- (expect as any).objectContaining({
3043
- email: 'new@example.com',
3044
- mfaEnabled: false,
3045
- mfaMethods: [],
3046
- preferredMfaMethod: null,
3047
- }),
3048
- );
3049
- });
3050
-
3051
- it('should disable MFA when all devices are removed after phone change', async () => {
3052
- const userWithMfa = { ...mockUser, phone: '+1234567890', mfaEnabled: true };
3053
- const smsDevice = {
3054
- id: 1,
3055
- userId: mockUser.id,
3056
- type: MFAMethod.SMS,
3057
- phoneNumber: '+1234567890',
3058
- isActive: true,
3059
- };
3060
-
3061
- mockUserRepository.findOne.mockReset();
3062
- mockUserRepository.findOne
3063
- .mockResolvedValueOnce(userWithMfa as any)
3064
- .mockResolvedValueOnce(null)
3065
- .mockResolvedValueOnce({ ...userWithMfa, phone: '+1987654321', mfaEnabled: false } as any);
3066
-
3067
- mockMfaDeviceRepository.find
3068
- .mockResolvedValueOnce([smsDevice] as any) // Find SMS devices
3069
- .mockResolvedValueOnce([] as any); // No remaining devices
3070
-
3071
- mockMfaDeviceRepository.delete.mockResolvedValue({ affected: 1 } as any);
3072
-
3073
- await service.updateUserAttributes(mockUser.sub, {
3074
- phone: '+1987654321',
3075
- });
3076
-
3077
- expect(mockUserRepository.update).toHaveBeenCalledWith(
3078
- mockUser.id,
3079
- (expect as any).objectContaining({
3080
- phone: '+1987654321',
3081
- mfaEnabled: false,
3082
- mfaMethods: [],
3083
- preferredMfaMethod: null,
3084
- }),
3085
- );
3086
- });
3087
- });
3088
-
3089
- describe('Uniqueness constraints', () => {
3090
- it('should throw NAuthException if email already exists', async () => {
3091
- mockUserRepository.findOne.mockReset();
3092
- mockUserRepository.findOne
3093
- .mockResolvedValueOnce(mockUser as any) // First call for user lookup by sub
3094
- .mockResolvedValueOnce({ id: 999 } as any); // Second call for email uniqueness check
3095
-
3096
- try {
3097
- await service.updateUserAttributes(mockUser.sub, {
3098
- email: 'existing@example.com',
3099
- });
3100
- fail('Should have thrown NAuthException');
3101
- } catch (error: any) {
3102
- expect(error).toBeInstanceOf(NAuthException);
3103
- expect(error.code).toBe(AuthErrorCode.VALIDATION_FAILED);
3104
- }
3105
- });
3106
-
3107
- it('should throw NAuthException if phone already exists', async () => {
3108
- mockUserRepository.findOne.mockReset();
3109
- mockUserRepository.findOne
3110
- .mockResolvedValueOnce(mockUser as any) // First call for user lookup by sub
3111
- .mockResolvedValueOnce({ id: 999 } as any); // Second call for phone uniqueness check
3112
-
3113
- try {
3114
- await service.updateUserAttributes(mockUser.sub, {
3115
- phone: '+1234567890',
3116
- });
3117
- fail('Should have thrown NAuthException');
3118
- } catch (error: any) {
3119
- expect(error).toBeInstanceOf(NAuthException);
3120
- expect(error.code).toBe(AuthErrorCode.VALIDATION_FAILED);
3121
- }
3122
- });
3123
-
3124
- it('should throw NAuthException if username already exists', async () => {
3125
- mockUserRepository.findOne.mockReset();
3126
- mockUserRepository.findOne
3127
- .mockResolvedValueOnce(mockUser as any) // First call for user lookup by sub
3128
- .mockResolvedValueOnce({ id: 999 } as any); // Second call for username uniqueness check
3129
-
3130
- try {
3131
- await service.updateUserAttributes(mockUser.sub, {
3132
- username: 'existinguser',
3133
- });
3134
- fail('Should have thrown NAuthException');
3135
- } catch (error: any) {
3136
- expect(error).toBeInstanceOf(NAuthException);
3137
- expect(error.code).toBe(AuthErrorCode.VALIDATION_FAILED);
3138
- }
3139
- });
3140
-
3141
- it('should allow updating to same email', async () => {
3142
- mockUserRepository.findOne.mockReset();
3143
- mockUserRepository.findOne
3144
- .mockResolvedValueOnce(mockUser as any) // First call for user lookup by sub
3145
- .mockResolvedValueOnce(null) // Email uniqueness check (not found = OK, because it's the same user)
3146
- .mockResolvedValueOnce(mockUser as any); // Final fetch by id
3147
-
3148
- await service.updateUserAttributes(mockUser.sub, {
3149
- email: mockUser.email,
3150
- });
3151
-
3152
- expect(mockUserRepository.update).toHaveBeenCalled();
3153
- });
3154
- });
3155
-
3156
- describe('User not found', () => {
3157
- it('should throw NAuthException if user not found', async () => {
3158
- mockUserRepository.findOne.mockResolvedValue(null);
3159
-
3160
- try {
3161
- await service.updateUserAttributes(mockUser.sub, updateData);
3162
- fail('Should have thrown NAuthException');
3163
- } catch (error: any) {
3164
- expect(error).toBeInstanceOf(NAuthException);
3165
- expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
3166
- }
3167
- });
3168
- });
3169
-
3170
- describe('MFA device management', () => {
3171
- it('should deactivate SMS MFA devices when phone changes', async () => {
3172
- const oldPhone = '+1234567890';
3173
- const userWithPhone = { ...mockUser, phone: oldPhone };
3174
- mockUserRepository.findOne.mockReset();
3175
- mockUserRepository.findOne
3176
- .mockResolvedValueOnce(userWithPhone as any) // Initial lookup by sub
3177
- .mockResolvedValueOnce(null) // Phone uniqueness check
3178
- .mockResolvedValueOnce({ ...userWithPhone, phone: '+1987654321' } as any); // Final fetch by id
3179
- mockMfaDeviceRepository.find.mockResolvedValue([
3180
- { id: 1, type: MFAMethod.SMS, phoneNumber: oldPhone, isActive: true },
3181
- ] as any);
3182
- mockMfaDeviceRepository.find
3183
- .mockResolvedValueOnce([{ id: 1, type: MFAMethod.SMS, phoneNumber: oldPhone, isActive: true }] as any)
3184
- .mockResolvedValueOnce([] as any); // Check for remaining active devices
3185
- mockMfaDeviceRepository.update.mockResolvedValue({ affected: 1 } as any);
3186
-
3187
- await service.updateUserAttributes(mockUser.sub, {
3188
- phone: '+1987654321',
3189
- });
3190
-
3191
- expect(mockMfaDeviceRepository.find).toHaveBeenCalled();
3192
- expect(mockMfaDeviceRepository.update).toHaveBeenCalled();
3193
- });
3194
-
3195
- it('should not deactivate SMS devices if phone unchanged', async () => {
3196
- const userWithPhone = { ...mockUser, phone: '+1234567890' };
3197
- mockUserRepository.findOne.mockReset();
3198
- mockUserRepository.findOne
3199
- .mockResolvedValueOnce(userWithPhone as any) // Initial lookup
3200
- .mockResolvedValueOnce(userWithPhone as any); // Final fetch
3201
-
3202
- await service.updateUserAttributes(mockUser.sub, {
3203
- firstName: 'New',
3204
- });
3205
-
3206
- expect(mockMfaDeviceRepository.find).not.toHaveBeenCalled();
3207
- });
3208
-
3209
- it('should deactivate SMS MFA devices when phone changes even if retainVerification is true', async () => {
3210
- const oldPhone = '+1234567890';
3211
- const userWithPhone = { ...mockUser, phone: oldPhone, isPhoneVerified: true };
3212
- mockUserRepository.findOne.mockReset();
3213
- mockUserRepository.findOne
3214
- .mockResolvedValueOnce(userWithPhone as any) // Initial lookup
3215
- .mockResolvedValueOnce(null) // Phone uniqueness check
3216
- .mockResolvedValueOnce({ ...userWithPhone, phone: '+1987654321' } as any); // Final fetch
3217
- mockMfaDeviceRepository.find
3218
- .mockResolvedValueOnce([{ id: 1, type: MFAMethod.SMS, phoneNumber: oldPhone, isActive: true }] as any) // Find SMS devices with old phone
3219
- .mockResolvedValueOnce([] as any); // Check for remaining active devices
3220
- mockMfaDeviceRepository.update.mockResolvedValue({ affected: 1 } as any);
3221
-
3222
- await service.updateUserAttributes(mockUser.sub, {
3223
- phone: '+1987654321',
3224
- retainVerification: true,
3225
- });
3226
-
3227
- // Should preserve verification status
3228
- expect(mockUserRepository.update).toHaveBeenCalledWith(
3229
- mockUser.id,
3230
- (expect as any).objectContaining({
3231
- phone: '+1987654321',
3232
- isPhoneVerified: true, // Preserved because retainVerification is true
3233
- }),
3234
- );
3235
-
3236
- // Should still deactivate MFA devices regardless of retainVerification
3237
- expect(mockMfaDeviceRepository.find).toHaveBeenCalled();
3238
- expect(mockMfaDeviceRepository.update).toHaveBeenCalled();
3239
- });
3240
- });
3241
-
3242
- describe('Error handling', () => {
3243
- it('should handle database update errors gracefully', async () => {
3244
- mockUserRepository.update.mockRejectedValue(new Error('Database error'));
3245
-
3246
- try {
3247
- await service.updateUserAttributes(mockUser.sub, updateData);
3248
- fail('Should have thrown Error');
3249
- } catch (error: any) {
3250
- expect(error).toBeInstanceOf(Error);
3251
- }
3252
- });
3253
-
3254
- it('should handle audit logging errors gracefully', async () => {
3255
- mockUserRepository.findOne.mockReset();
3256
- mockUserRepository.findOne
3257
- .mockResolvedValueOnce(mockUser as any) // Initial lookup by sub
3258
- .mockResolvedValueOnce(null) // Uniqueness checks
3259
- .mockResolvedValueOnce({ ...mockUser, ...updateData } as any); // Final fetch by id
3260
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
3261
-
3262
- await service.updateUserAttributes(mockUser.sub, updateData);
3263
-
3264
- // Should still complete update despite audit error
3265
- expect(mockUserRepository.update).toHaveBeenCalled();
3266
- expect(mockLogger.error).toHaveBeenCalled();
3267
- });
3268
-
3269
- it('should handle MFA device deactivation errors gracefully', async () => {
3270
- const userWithPhone = { ...mockUser, phone: '+1234567890' };
3271
- mockUserRepository.findOne.mockReset();
3272
- mockUserRepository.findOne
3273
- .mockResolvedValueOnce(userWithPhone as any) // Initial lookup by sub
3274
- .mockResolvedValueOnce(null) // Phone uniqueness check
3275
- .mockResolvedValueOnce({ ...userWithPhone, phone: '+1987654321' } as any); // Final fetch by id
3276
- mockMfaDeviceRepository.find.mockRejectedValue(new Error('Database error'));
3277
-
3278
- // Should still complete update despite MFA device error
3279
- await service.updateUserAttributes(mockUser.sub, {
3280
- phone: '+1987654321',
3281
- });
3282
-
3283
- expect(mockUserRepository.update).toHaveBeenCalled();
3284
- expect(mockLogger.warn).toHaveBeenCalled();
3285
- });
3286
- });
3287
-
3288
- describe('Edge cases', () => {
3289
- it('should handle partial updates (only some fields)', async () => {
3290
- mockUserRepository.findOne.mockReset();
3291
- mockUserRepository.findOne
3292
- .mockResolvedValueOnce(mockUser as any)
3293
- .mockResolvedValueOnce({ ...mockUser, firstName: 'NewFirst' } as any);
3294
-
3295
- await service.updateUserAttributes(mockUser.sub, {
3296
- firstName: 'NewFirst',
3297
- });
3298
-
3299
- expect(mockUserRepository.update).toHaveBeenCalledWith(
3300
- mockUser.id,
3301
- (expect as any).objectContaining({
3302
- firstName: 'NewFirst',
3303
- }),
3304
- );
3305
- });
3306
-
3307
- it('should handle empty metadata', async () => {
3308
- const userWithMetadata = { ...mockUser, metadata: { key1: 'value1' } };
3309
- mockUserRepository.findOne.mockReset();
3310
- mockUserRepository.findOne
3311
- .mockResolvedValueOnce(userWithMetadata as any) // Initial lookup by sub
3312
- .mockResolvedValueOnce(userWithMetadata as any); // Final fetch by id
3313
-
3314
- await service.updateUserAttributes(mockUser.sub, {
3315
- metadata: {},
3316
- });
3317
-
3318
- expect(mockUserRepository.update).toHaveBeenCalled();
3319
- });
3320
-
3321
- it('should handle null metadata', async () => {
3322
- const userWithNullMetadata = { ...mockUser, metadata: null };
3323
- mockUserRepository.findOne.mockReset();
3324
- mockUserRepository.findOne
3325
- .mockResolvedValueOnce(userWithNullMetadata as any) // Initial lookup by sub
3326
- .mockResolvedValueOnce({ ...userWithNullMetadata, metadata: { key: 'value' } } as any); // Final fetch by id
3327
-
3328
- await service.updateUserAttributes(mockUser.sub, {
3329
- metadata: { key: 'value' },
3330
- });
3331
-
3332
- expect(mockUserRepository.update).toHaveBeenCalledWith(
3333
- mockUser.id,
3334
- (expect as any).objectContaining({
3335
- metadata: (expect as any).objectContaining({
3336
- key: 'value',
3337
- }),
3338
- }),
3339
- );
3340
- });
3341
- });
3342
- });
3343
-
3344
- // ============================================================================
3345
- // respondToChallenge - MFA_REQUIRED Tests (formerly verifyMFA)
3346
- // ============================================================================
3347
-
3348
- describe('respondToChallenge() - MFA_REQUIRED', () => {
3349
- const mockChallengeSession = {
3350
- id: 'challenge-session-123',
3351
- sessionToken: 'challenge-session-123',
3352
- user: mockUser,
3353
- challengeName: AuthChallenge.MFA_REQUIRED,
3354
- metadata: {},
3355
- };
3356
-
3357
- beforeEach(() => {
3358
- mockChallengeService.validateSession.mockResolvedValue(mockChallengeSession as any);
3359
- mockChallengeService.validateAndConsumeSession.mockResolvedValue(mockChallengeSession as any);
3360
- mockMfaService.verifyCode.mockResolvedValue(true);
3361
- mockJwtService.generateTokenFamily.mockReturnValue('token-family-123');
3362
- mockJwtService.generateTokenPair.mockResolvedValue({
3363
- accessToken: 'access-token',
3364
- refreshToken: 'refresh-token',
3365
- expiresIn: 3600,
3366
- });
3367
- mockJwtService.hashToken.mockReturnValue('hashed-token');
3368
- mockJwtService.validateAccessToken.mockResolvedValue({
3369
- valid: true,
3370
- payload: { exp: Math.floor(Date.now() / 1000) + 3600 },
3371
- } as any);
3372
- mockJwtService.validateRefreshToken.mockResolvedValue({
3373
- valid: true,
3374
- payload: { exp: Math.floor(Date.now() / 1000) + 86400 },
3375
- } as any);
3376
- mockSessionService.createSessionAtomic.mockResolvedValue({
3377
- session: mockSession,
3378
- extra: {
3379
- accessToken: 'access-token',
3380
- refreshToken: 'refresh-token',
3381
- },
3382
- } as any);
3383
- mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
3384
- });
3385
-
3386
- describe('Successful MFA verification', () => {
3387
- it('should verify TOTP code successfully', async () => {
3388
- const response: VerifyMFACodeResponse = {
3389
- session: 'challenge-session-123',
3390
- type: 'MFA_REQUIRED',
3391
- method: 'totp',
3392
- code: '123456',
3393
- };
3394
- const result = await service.respondToChallenge(createRespondChallengeDto(response));
3395
-
3396
- expect(mockChallengeService.validateSession).toHaveBeenCalledWith('challenge-session-123');
3397
- expect(mockMfaService.verifyCode).toHaveBeenCalledWith(mockUser, 'totp', '123456');
3398
- expect(mockChallengeService.validateAndConsumeSession).toHaveBeenCalledWith(
3399
- 'challenge-session-123',
3400
- AuthChallenge.MFA_REQUIRED,
3401
- );
3402
- expect(result).toBeDefined();
3403
- if ('accessToken' in result) {
3404
- expect(result.accessToken).toBe('access-token');
3405
- expect(result.refreshToken).toBe('refresh-token');
3406
- }
3407
- });
3408
-
3409
- it('should verify SMS code successfully', async () => {
3410
- const response: VerifyMFACodeResponse = {
3411
- session: 'challenge-session-123',
3412
- type: 'MFA_REQUIRED',
3413
- method: 'sms',
3414
- code: '123456',
3415
- };
3416
- await service.respondToChallenge(createRespondChallengeDto(response));
3417
-
3418
- expect(mockMfaService.verifyCode).toHaveBeenCalledWith(mockUser, 'sms', '123456');
3419
- });
3420
-
3421
- it('should verify backup code successfully', async () => {
3422
- const response: VerifyMFACodeResponse = {
3423
- session: 'challenge-session-123',
3424
- type: 'MFA_REQUIRED',
3425
- method: 'backup',
3426
- code: 'backup123',
3427
- };
3428
- await service.respondToChallenge(createRespondChallengeDto(response));
3429
-
3430
- expect(mockMfaService.verifyCode).toHaveBeenCalledWith(mockUser, 'backup', 'backup123');
3431
- });
3432
-
3433
- it('should verify passkey credential successfully', async () => {
3434
- const credential = { id: 'passkey-id', response: {} };
3435
- const sessionWithPasskey = {
3436
- ...mockChallengeSession,
3437
- metadata: { passkeyChallenge: 'expected-challenge' },
3438
- };
3439
- mockChallengeService.validateSession.mockResolvedValue(sessionWithPasskey as any);
3440
- mockMfaService.verifyCode.mockResolvedValue(true);
3441
-
3442
- const response: VerifyMFAPasskeyResponse = {
3443
- session: 'challenge-session-123',
3444
- type: 'MFA_REQUIRED',
3445
- method: 'passkey',
3446
- credential,
3447
- };
3448
- await service.respondToChallenge(createRespondChallengeDto(response));
3449
-
3450
- expect(mockMfaService.verifyCode).toHaveBeenCalledWith(mockUser, 'passkey', {
3451
- credential,
3452
- expectedChallenge: 'expected-challenge',
3453
- });
3454
- });
3455
-
3456
- it('should record MFA_VERIFICATION_SUCCESS audit event', async () => {
3457
- const response: VerifyMFACodeResponse = {
3458
- session: 'challenge-session-123',
3459
- type: 'MFA_REQUIRED',
3460
- method: 'totp',
3461
- code: '123456',
3462
- };
3463
- await service.respondToChallenge(createRespondChallengeDto(response));
3464
-
3465
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
3466
- (expect as any).objectContaining({
3467
- userId: mockUser.id,
3468
- eventType: AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
3469
- eventStatus: 'SUCCESS',
3470
- challengeSessionId: 'challenge-session-123',
3471
- authMethod: 'totp',
3472
- }),
3473
- );
3474
- });
3475
-
3476
- it('should update user last login after successful verification', async () => {
3477
- const response: VerifyMFACodeResponse = {
3478
- session: 'challenge-session-123',
3479
- type: 'MFA_REQUIRED',
3480
- method: 'totp',
3481
- code: '123456',
3482
- };
3483
- await service.respondToChallenge(createRespondChallengeDto(response));
3484
-
3485
- // Note: User update happens in determineAuthResponse, not directly in MFA verification
3486
- // This test may need adjustment based on actual implementation
3487
- expect(mockChallengeHelper.determineAuthResponse).toHaveBeenCalled();
3488
- });
3489
- });
3490
-
3491
- describe('Invalid MFA verification', () => {
3492
- it('should throw NAuthException for invalid TOTP code', async () => {
3493
- mockMfaService.verifyCode.mockResolvedValue(false);
3494
-
3495
- const response: VerifyMFACodeResponse = {
3496
- session: 'challenge-session-123',
3497
- type: 'MFA_REQUIRED',
3498
- method: 'totp',
3499
- code: '123456',
3500
- };
3501
-
3502
- try {
3503
- await service.respondToChallenge(createRespondChallengeDto(response));
3504
- fail('Should have thrown NAuthException');
3505
- } catch (error: any) {
3506
- expect(error).toBeInstanceOf(NAuthException);
3507
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_CODE_INVALID);
3508
- }
3509
- });
3510
-
3511
- it('should record MFA_VERIFICATION_FAILED audit event', async () => {
3512
- mockMfaService.verifyCode.mockResolvedValue(false);
3513
-
3514
- const response: VerifyMFACodeResponse = {
3515
- session: 'challenge-session-123',
3516
- type: 'MFA_REQUIRED',
3517
- method: 'totp',
3518
- code: '123456',
3519
- };
3520
-
3521
- try {
3522
- await service.respondToChallenge(createRespondChallengeDto(response));
3523
- } catch {
3524
- // Expected to throw
3525
- }
3526
-
3527
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
3528
- (expect as any).objectContaining({
3529
- userId: mockUser.id,
3530
- eventType: AuthAuditEventType.MFA_VERIFICATION_FAILED,
3531
- eventStatus: 'FAILURE',
3532
- challengeSessionId: 'challenge-session-123',
3533
- authMethod: 'totp',
3534
- }),
3535
- );
3536
- });
3537
-
3538
- it('should increment challenge attempts on failure', async () => {
3539
- mockMfaService.verifyCode.mockResolvedValue(false);
3540
-
3541
- const response: VerifyMFACodeResponse = {
3542
- session: 'challenge-session-123',
3543
- type: 'MFA_REQUIRED',
3544
- method: 'totp',
3545
- code: '123456',
3546
- };
3547
-
3548
- try {
3549
- await service.respondToChallenge(createRespondChallengeDto(response));
3550
- } catch {
3551
- // Expected to throw
3552
- }
3553
-
3554
- expect(mockChallengeService.incrementAttempts).toHaveBeenCalled();
3555
- });
3556
-
3557
- it('should throw NAuthException if code is missing for non-passkey methods', async () => {
3558
- const response: VerifyMFACodeResponse = {
3559
- session: 'challenge-session-123',
3560
- type: 'MFA_REQUIRED',
3561
- method: 'totp',
3562
- code: '', // Empty code
3563
- };
3564
-
3565
- try {
3566
- await service.respondToChallenge(createRespondChallengeDto(response));
3567
- fail('Should have thrown NAuthException');
3568
- } catch (error: any) {
3569
- expect(error).toBeInstanceOf(NAuthException);
3570
- expect(error.code).toBe(AuthErrorCode.VALIDATION_FAILED);
3571
- }
3572
- });
3573
-
3574
- it('should throw NAuthException if credential is missing for passkey', async () => {
3575
- const response: VerifyMFAPasskeyResponse = {
3576
- session: 'challenge-session-123',
3577
- type: 'MFA_REQUIRED',
3578
- method: 'passkey',
3579
- credential: {} as any, // Empty credential - validation will fail
3580
- };
3581
-
3582
- try {
3583
- await service.respondToChallenge(createRespondChallengeDto(response));
3584
- fail('Should have thrown NAuthException');
3585
- } catch (error: any) {
3586
- expect(error).toBeInstanceOf(NAuthException);
3587
- // Validation happens in validateChallengeParams which throws VALIDATION_FAILED
3588
- // but empty object might pass validation, so it could throw CHALLENGE_INVALID
3589
- expect([AuthErrorCode.VALIDATION_FAILED, AuthErrorCode.CHALLENGE_INVALID]).toContain(error.code);
3590
- }
3591
- });
3592
-
3593
- it('should throw NAuthException if passkey challenge is missing in session', async () => {
3594
- const response: VerifyMFAPasskeyResponse = {
3595
- session: 'challenge-session-123',
3596
- type: 'MFA_REQUIRED',
3597
- method: 'passkey',
3598
- credential: { id: 'passkey' },
3599
- };
3600
-
3601
- try {
3602
- await service.respondToChallenge(createRespondChallengeDto(response));
3603
- fail('Should have thrown NAuthException');
3604
- } catch (error: any) {
3605
- expect(error).toBeInstanceOf(NAuthException);
3606
- expect(error.code).toBe(AuthErrorCode.CHALLENGE_INVALID);
3607
- }
3608
- });
3609
- });
3610
-
3611
- describe('Error handling', () => {
3612
- it('should throw NAuthException if challenge session is invalid', async () => {
3613
- mockChallengeService.validateSession.mockRejectedValue(
3614
- new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'Invalid session'),
3615
- );
3616
-
3617
- const response: VerifyMFACodeResponse = {
3618
- session: 'invalid-session',
3619
- type: 'MFA_REQUIRED',
3620
- method: 'totp',
3621
- code: '123456',
3622
- };
3623
-
3624
- try {
3625
- await service.respondToChallenge(createRespondChallengeDto(response));
3626
- fail('Should have thrown NAuthException');
3627
- } catch (error: any) {
3628
- expect(error).toBeInstanceOf(NAuthException);
3629
- expect(error.code).toBe(AuthErrorCode.CHALLENGE_INVALID);
3630
- }
3631
- });
3632
-
3633
- it('should throw NAuthException if user not found in challenge session', async () => {
3634
- const sessionWithoutUser = { ...mockChallengeSession, user: null };
3635
- mockChallengeService.validateSession.mockResolvedValue(sessionWithoutUser as any);
3636
-
3637
- const response: VerifyMFACodeResponse = {
3638
- session: 'challenge-session-123',
3639
- type: 'MFA_REQUIRED',
3640
- method: 'totp',
3641
- code: '123456',
3642
- };
3643
-
3644
- try {
3645
- await service.respondToChallenge(createRespondChallengeDto(response));
3646
- fail('Should have thrown NAuthException');
3647
- } catch (error: any) {
3648
- expect(error).toBeInstanceOf(NAuthException);
3649
- expect(error.code).toBe(AuthErrorCode.CHALLENGE_INVALID);
3650
- }
3651
- });
3652
-
3653
- it('should throw NAuthException if MFA service is not available', async () => {
3654
- const serviceWithoutMfa = new AuthService(
3655
- mockUserRepository,
3656
- mockLoginAttemptRepository,
3657
- mockPasswordService,
3658
- mockJwtService,
3659
- mockSessionService,
3660
- mockChallengeService,
3661
- mockChallengeHelper,
3662
- mockEmailVerificationService,
3663
- mockClientInfoService,
3664
- mockAccountLockoutStorage,
3665
- mockConfig,
3666
- mockLogger,
3667
- mockAuditService,
3668
- mockPhoneVerificationService,
3669
- undefined, // No MFA service
3670
- mockMfaDeviceRepository,
3671
- mockTrustedDeviceService,
3672
- );
3673
-
3674
- const response: VerifyMFACodeResponse = {
3675
- session: 'challenge-session-123',
3676
- type: 'MFA_REQUIRED',
3677
- method: 'totp',
3678
- code: '123456',
3679
- };
3680
-
3681
- try {
3682
- await serviceWithoutMfa.respondToChallenge(createRespondChallengeDto(response));
3683
- fail('Should have thrown NAuthException');
3684
- } catch (error: any) {
3685
- expect(error).toBeInstanceOf(NAuthException);
3686
- expect(error.code).toBe(AuthErrorCode.INTERNAL_ERROR);
3687
- }
3688
- });
3689
-
3690
- it('should handle audit logging errors gracefully', async () => {
3691
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
3692
-
3693
- const response: VerifyMFACodeResponse = {
3694
- session: 'challenge-session-123',
3695
- type: 'MFA_REQUIRED',
3696
- method: 'totp',
3697
- code: '123456',
3698
- };
3699
- const result = await service.respondToChallenge(createRespondChallengeDto(response));
3700
-
3701
- // Should still complete verification despite audit error
3702
- expect(result).toBeDefined();
3703
- expect(mockLogger.error).toHaveBeenCalled();
3704
- });
3705
- });
3706
- });
3707
-
3708
- // ============================================================================
3709
- // trustDevice Tests
3710
- // ============================================================================
3711
-
3712
- describe('trustDevice()', () => {
3713
- beforeEach(() => {
3714
- mockConfig.mfa = {
3715
- enabled: true,
3716
- enforcement: 'OPTIONAL',
3717
- rememberDevices: 'user_opt_in',
3718
- rememberDeviceDays: 30,
3719
- };
3720
- mockSessionService.findById.mockResolvedValue(mockSession as any);
3721
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
3722
- mockTrustedDeviceService.isDeviceTrusted.mockResolvedValue(false);
3723
- mockTrustedDeviceService.createTrustedDevice.mockResolvedValue('device-token-123');
3724
- });
3725
-
3726
- describe('Successful device trust', () => {
3727
- it('should create trusted device token successfully', async () => {
3728
- const result = await service.trustDevice('1');
3729
-
3730
- expect(mockSessionService.findById).toHaveBeenCalledWith(1);
3731
- expect(mockTrustedDeviceService.createTrustedDevice).toHaveBeenCalled();
3732
- expect(result.deviceToken).toBe('device-token-123');
3733
- });
3734
-
3735
- it('should return existing device token if device already trusted', async () => {
3736
- mockTrustedDeviceService.isDeviceTrusted.mockResolvedValue(true);
3737
- mockClientInfo.deviceToken = 'existing-token';
3738
-
3739
- const result = await service.trustDevice('1');
3740
-
3741
- expect(result.deviceToken).toBe('existing-token');
3742
- expect(mockTrustedDeviceService.createTrustedDevice).not.toHaveBeenCalled();
3743
- });
3744
-
3745
- it('should revoke existing untrusted device token before creating new one', async () => {
3746
- mockClientInfo.deviceToken = 'existing-untrusted-token';
3747
- mockTrustedDeviceService.isDeviceTrusted.mockResolvedValue(false);
3748
-
3749
- await service.trustDevice('1');
3750
-
3751
- expect(mockTrustedDeviceService.revokeTrustedDevice).toHaveBeenCalled();
3752
- expect(mockTrustedDeviceService.createTrustedDevice).toHaveBeenCalled();
3753
- });
3754
-
3755
- it('should record DEVICE_TRUSTED audit event', async () => {
3756
- await service.trustDevice('1');
3757
-
3758
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
3759
- (expect as any).objectContaining({
3760
- userId: mockUser.id,
3761
- eventType: AuthAuditEventType.DEVICE_TRUSTED,
3762
- eventStatus: 'SUCCESS',
3763
- deviceId: 'device-token-123',
3764
- sessionId: mockSession.id,
3765
- }),
3766
- );
3767
- });
3768
- });
3769
-
3770
- describe('Error handling', () => {
3771
- it('should throw NAuthException if rememberDevices is not user_opt_in', async () => {
3772
- mockConfig.mfa = {
3773
- enabled: true,
3774
- enforcement: 'OPTIONAL',
3775
- rememberDevices: 'always',
3776
- rememberDeviceDays: 30,
3777
- };
3778
-
3779
- try {
3780
- await service.trustDevice('1');
3781
- fail('Should have thrown NAuthException');
3782
- } catch (error: any) {
3783
- expect(error).toBeInstanceOf(NAuthException);
3784
- expect(error.code).toBe(AuthErrorCode.FORBIDDEN);
3785
- }
3786
- });
3787
-
3788
- it('should throw NAuthException if trusted device service is not available', async () => {
3789
- const serviceWithoutTrustedDevice = new AuthService(
3790
- mockUserRepository,
3791
- mockLoginAttemptRepository,
3792
- mockPasswordService,
3793
- mockJwtService,
3794
- mockSessionService,
3795
- mockChallengeService,
3796
- mockChallengeHelper,
3797
- mockEmailVerificationService,
3798
- mockClientInfoService,
3799
- mockAccountLockoutStorage,
3800
- mockConfig,
3801
- mockLogger,
3802
- mockAuditService,
3803
- mockPhoneVerificationService,
3804
- mockMfaService,
3805
- mockMfaDeviceRepository,
3806
- undefined, // No trusted device service
3807
- );
3808
-
3809
- try {
3810
- await serviceWithoutTrustedDevice.trustDevice('1');
3811
- fail('Should have thrown NAuthException');
3812
- } catch (error: any) {
3813
- expect(error).toBeInstanceOf(NAuthException);
3814
- expect(error.code).toBe(AuthErrorCode.INTERNAL_ERROR);
3815
- }
3816
- });
3817
-
3818
- it('should throw NAuthException if session not found', async () => {
3819
- mockSessionService.findById.mockResolvedValue(null);
3820
-
3821
- try {
3822
- await service.trustDevice('1');
3823
- fail('Should have thrown NAuthException');
3824
- } catch (error: any) {
3825
- expect(error).toBeInstanceOf(NAuthException);
3826
- expect(error.code).toBe(AuthErrorCode.SESSION_NOT_FOUND);
3827
- }
3828
- });
3829
-
3830
- it('should throw NAuthException if session is revoked', async () => {
3831
- const revokedSession = { ...mockSession, isRevoked: true };
3832
- mockSessionService.findById.mockResolvedValue(revokedSession as any);
3833
-
3834
- try {
3835
- await service.trustDevice('1');
3836
- fail('Should have thrown NAuthException');
3837
- } catch (error: any) {
3838
- expect(error).toBeInstanceOf(NAuthException);
3839
- expect(error.code).toBe(AuthErrorCode.SESSION_NOT_FOUND);
3840
- }
3841
- });
3842
-
3843
- it('should throw NAuthException if user not found', async () => {
3844
- mockUserRepository.findOne.mockResolvedValue(null);
3845
-
3846
- try {
3847
- await service.trustDevice('1');
3848
- fail('Should have thrown NAuthException');
3849
- } catch (error: any) {
3850
- expect(error).toBeInstanceOf(NAuthException);
3851
- expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
3852
- }
3853
- });
3854
-
3855
- it('should handle audit logging errors gracefully', async () => {
3856
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
3857
-
3858
- const result = await service.trustDevice('1');
3859
-
3860
- // Should still complete trust operation despite audit error
3861
- expect(result.deviceToken).toBe('device-token-123');
3862
- expect(mockLogger.error).toHaveBeenCalled();
3863
- });
3864
- });
3865
- });
3866
-
3867
- // ============================================================================
3868
- // respondToChallenge Tests (formerly completeChallenge)
3869
- // ============================================================================
3870
-
3871
- describe('respondToChallenge()', () => {
3872
- const mockChallengeSession = {
3873
- id: 'challenge-session-123',
3874
- sessionToken: 'session-token',
3875
- user: mockUser,
3876
- challengeName: AuthChallenge.VERIFY_EMAIL,
3877
- metadata: {},
3878
- };
3879
-
3880
- beforeEach(() => {
3881
- mockChallengeService.validateSession.mockResolvedValue(mockChallengeSession as any);
3882
- mockChallengeService.validateAndConsumeSession.mockResolvedValue(mockChallengeSession as any);
3883
- // Query builder will be set up in individual tests as needed
3884
- mockChallengeHelper.determineAuthResponse.mockResolvedValue({
3885
- accessToken: 'access-token',
3886
- refreshToken: 'refresh-token',
3887
- accessTokenExpiresAt: Math.floor(Date.now() / 1000) + 3600,
3888
- refreshTokenExpiresAt: Math.floor(Date.now() / 1000) + 86400,
3889
- user: {
3890
- sub: 'user-123',
3891
- email: 'test@example.com',
3892
- isEmailVerified: true,
3893
- isPhoneVerified: false,
3894
- },
3895
- });
3896
- });
3897
-
3898
- describe('VERIFY_EMAIL challenge', () => {
3899
- it('should complete email verification challenge successfully', async () => {
3900
- // Mock findOne to return updated user after verification
3901
- const updatedUser = {
3902
- ...mockUser,
3903
- isEmailVerified: true,
3904
- isPhoneVerified: false,
3905
- };
3906
- mockUserRepository.findOne.mockResolvedValue(updatedUser as any);
3907
- mockEmailVerificationService.verifyEmailWithCode.mockResolvedValue({ message: 'Email verified' });
3908
-
3909
- const response: VerifyEmailResponse = {
3910
- session: 'session-token',
3911
- type: 'VERIFY_EMAIL',
3912
- code: '123456',
3913
- };
3914
- const result = await service.respondToChallenge(createRespondChallengeDto(response));
3915
-
3916
- expect(mockChallengeService.validateSession).toHaveBeenCalledWith('session-token');
3917
- // Note: verifyEmailWithCode is called with user.sub in the implementation
3918
- expect(mockEmailVerificationService.verifyEmailWithCode).toHaveBeenCalledWith(mockUser.sub, '123456');
3919
- expect(mockChallengeService.validateAndConsumeSession).toHaveBeenCalledWith(
3920
- 'session-token',
3921
- AuthChallenge.VERIFY_EMAIL,
3922
- );
3923
- expect(mockChallengeHelper.determineAuthResponse).toHaveBeenCalled();
3924
- expect(result).toBeDefined();
3925
- });
3926
- });
3927
-
3928
- describe('VERIFY_PHONE challenge', () => {
3929
- it('should complete phone verification challenge successfully', async () => {
3930
- const phoneVerifySession = {
3931
- ...mockChallengeSession,
3932
- challengeName: AuthChallenge.VERIFY_PHONE,
3933
- };
3934
- mockChallengeService.validateSession.mockResolvedValue(phoneVerifySession as any);
3935
- // Mock findOne to return updated user after verification
3936
- const updatedUser = {
3937
- ...mockUser,
3938
- isEmailVerified: true,
3939
- isPhoneVerified: true,
3940
- };
3941
- mockUserRepository.findOne.mockResolvedValue(updatedUser as any);
3942
- mockPhoneVerificationService.verifyPhoneWithCodeBySub.mockResolvedValue({
3943
- message: 'Phone verified',
3944
- });
3945
-
3946
- const response: VerifyPhoneResponse = {
3947
- session: 'session-token',
3948
- type: 'VERIFY_PHONE',
3949
- code: '123456',
3950
- };
3951
- const result = await service.respondToChallenge(createRespondChallengeDto(response));
3952
-
3953
- expect(mockChallengeService.validateSession).toHaveBeenCalledWith('session-token');
3954
- expect(mockPhoneVerificationService.verifyPhoneWithCodeBySub).toHaveBeenCalledWith(mockUser.sub, '123456');
3955
- expect(mockChallengeService.validateAndConsumeSession).toHaveBeenCalledWith(
3956
- 'session-token',
3957
- AuthChallenge.VERIFY_PHONE,
3958
- );
3959
- expect(result).toBeDefined();
3960
- });
3961
-
3962
- it('should handle phone collection before verification', async () => {
3963
- const phoneCollectSession = {
3964
- ...mockChallengeSession,
3965
- challengeName: AuthChallenge.VERIFY_PHONE,
3966
- user: mockUser,
3967
- };
3968
- mockChallengeService.validateSession.mockResolvedValue(phoneCollectSession as any);
3969
- mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
3970
- mockPhoneVerificationService.sendVerificationSMS.mockResolvedValue(undefined as any);
3971
- mockChallengeHelper.createChallengeResponse.mockResolvedValue({
3972
- challengeName: AuthChallenge.VERIFY_PHONE,
3973
- session: 'challenge-session-token',
3974
- challengeParameters: {},
3975
- } as any);
3976
-
3977
- const response: CollectPhoneResponse = {
3978
- session: 'session-token',
3979
- type: 'VERIFY_PHONE',
3980
- phone: '+1234567890',
3981
- };
3982
- const result = await service.respondToChallenge(createRespondChallengeDto(response));
3983
-
3984
- expect(mockChallengeService.validateSession).toHaveBeenCalledWith('session-token');
3985
- expect(mockUserRepository.update).toHaveBeenCalledWith({ sub: mockUser.sub }, { phone: '+1234567890' });
3986
- expect(mockPhoneVerificationService.sendVerificationSMS).toHaveBeenCalledWith(mockUser.sub);
3987
- expect(result.challengeName).toBeDefined();
3988
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_PHONE);
3989
- });
3990
-
3991
- it('should throw NAuthException for invalid phone format', async () => {
3992
- const phoneCollectSession = {
3993
- ...mockChallengeSession,
3994
- challengeName: AuthChallenge.VERIFY_PHONE,
3995
- user: mockUser,
3996
- };
3997
- mockChallengeService.validateSession.mockResolvedValue(phoneCollectSession as any);
3998
-
3999
- const response: CollectPhoneResponse = {
4000
- session: 'session-token',
4001
- type: 'VERIFY_PHONE',
4002
- phone: 'invalid-phone',
4003
- };
4004
-
4005
- try {
4006
- await service.respondToChallenge(createRespondChallengeDto(response));
4007
- fail('Should have thrown NAuthException');
4008
- } catch (error: any) {
4009
- expect(error).toBeInstanceOf(NAuthException);
4010
- // Phone format validation happens in handleVerifyPhone, which throws INVALID_PHONE_FORMAT
4011
- // But validation might happen earlier in validateChallengeParams
4012
- expect([AuthErrorCode.INVALID_PHONE_FORMAT, AuthErrorCode.VALIDATION_FAILED]).toContain(error.code);
4013
- }
4014
- });
4015
- });
4016
-
4017
- describe('FORCE_CHANGE_PASSWORD challenge', () => {
4018
- it('should complete password change challenge successfully', async () => {
4019
- const passwordChangeSession = {
4020
- ...mockChallengeSession,
4021
- challengeName: AuthChallenge.FORCE_CHANGE_PASSWORD,
4022
- };
4023
- mockChallengeService.validateSession.mockResolvedValue(passwordChangeSession as any);
4024
- mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
4025
- mockPasswordService.hashPassword.mockResolvedValue('new-hashed-password');
4026
- mockUserRepository.findOne.mockResolvedValue({ ...mockUser, mustChangePassword: false } as any);
4027
-
4028
- const response: ForceChangePasswordResponse = {
4029
- session: 'session-token',
4030
- type: 'FORCE_CHANGE_PASSWORD',
4031
- newPassword: 'NewPassword123!',
4032
- };
4033
- const result = await service.respondToChallenge(createRespondChallengeDto(response));
4034
-
4035
- expect(mockChallengeService.validateSession).toHaveBeenCalledWith('session-token');
4036
- expect(mockPasswordService.validatePassword).toHaveBeenCalled();
4037
- expect(mockPasswordService.hashPassword).toHaveBeenCalledWith('NewPassword123!');
4038
- expect(mockUserRepository.update).toHaveBeenCalledWith(
4039
- { sub: mockUser.sub },
4040
- (expect as any).objectContaining({
4041
- passwordHash: 'new-hashed-password',
4042
- mustChangePassword: false,
4043
- }),
4044
- );
4045
- expect(result).toBeDefined();
4046
- });
4047
-
4048
- it('should throw NAuthException if new password is missing', async () => {
4049
- const passwordChangeSession = {
4050
- ...mockChallengeSession,
4051
- challengeName: AuthChallenge.FORCE_CHANGE_PASSWORD,
4052
- };
4053
- mockChallengeService.validateSession.mockResolvedValue(passwordChangeSession as any);
4054
-
4055
- const response: ForceChangePasswordResponse = {
4056
- session: 'session-token',
4057
- type: 'FORCE_CHANGE_PASSWORD',
4058
- newPassword: '', // Empty password
4059
- };
4060
-
4061
- try {
4062
- await service.respondToChallenge(createRespondChallengeDto(response));
4063
- fail('Should have thrown NAuthException');
4064
- } catch (error: any) {
4065
- expect(error).toBeInstanceOf(NAuthException);
4066
- expect(error.code).toBe(AuthErrorCode.VALIDATION_FAILED);
4067
- }
4068
- });
4069
-
4070
- it('should throw NAuthException if new password is weak', async () => {
4071
- const passwordChangeSession = {
4072
- ...mockChallengeSession,
4073
- challengeName: AuthChallenge.FORCE_CHANGE_PASSWORD,
4074
- };
4075
- mockChallengeService.validateSession.mockResolvedValue(passwordChangeSession as any);
4076
- mockPasswordService.validatePassword.mockResolvedValue({
4077
- valid: false,
4078
- errors: ['Password too weak'],
4079
- });
4080
-
4081
- const response: ForceChangePasswordResponse = {
4082
- session: 'session-token',
4083
- type: 'FORCE_CHANGE_PASSWORD',
4084
- newPassword: 'weak',
4085
- };
4086
-
4087
- try {
4088
- await service.respondToChallenge(createRespondChallengeDto(response));
4089
- fail('Should have thrown NAuthException');
4090
- } catch (error: any) {
4091
- expect(error).toBeInstanceOf(NAuthException);
4092
- // Password validation happens in handleForceChangePassword, which throws WEAK_PASSWORD
4093
- // But validation might happen earlier in validateChallengeParams
4094
- expect([AuthErrorCode.WEAK_PASSWORD, AuthErrorCode.VALIDATION_FAILED]).toContain(error.code);
4095
- }
4096
- });
4097
- });
4098
-
4099
- describe('MFA_SETUP_REQUIRED challenge', () => {
4100
- it('should complete MFA setup challenge successfully', async () => {
4101
- const mfaSetupSession = {
4102
- ...mockChallengeSession,
4103
- challengeName: AuthChallenge.MFA_SETUP_REQUIRED,
4104
- };
4105
- mockChallengeService.validateSession.mockResolvedValue(mfaSetupSession as any);
4106
- const updatedUser = { ...mockUser, mfaEnabled: true };
4107
- mockUserRepository.findOne.mockResolvedValue(updatedUser as any);
4108
-
4109
- const response: MFASetupResponse = {
4110
- session: 'session-token',
4111
- type: 'MFA_SETUP_REQUIRED',
4112
- method: 'totp',
4113
- setupData: { code: '123456' },
4114
- };
4115
- const result = await service.respondToChallenge(createRespondChallengeDto(response));
4116
-
4117
- expect(mockChallengeService.validateSession).toHaveBeenCalledWith('session-token');
4118
- expect(mockUserRepository.findOne).toHaveBeenCalledWith({ where: { sub: mockUser.sub } });
4119
- expect(mockChallengeHelper.determineAuthResponse).toHaveBeenCalledWith({
4120
- user: updatedUser,
4121
- config: mockConfig,
4122
- deviceToken: mockClientInfo.deviceToken,
4123
- isSocialLogin: false,
4124
- skipMFAVerification: true,
4125
- });
4126
- expect(result).toBeDefined();
4127
- });
4128
-
4129
- it('should throw NAuthException if user not found after MFA setup', async () => {
4130
- const mfaSetupSession = {
4131
- ...mockChallengeSession,
4132
- challengeName: AuthChallenge.MFA_SETUP_REQUIRED,
4133
- };
4134
- mockChallengeService.validateSession.mockResolvedValue(mfaSetupSession as any);
4135
- mockUserRepository.findOne.mockResolvedValue(null);
4136
-
4137
- const response: MFASetupResponse = {
4138
- session: 'session-token',
4139
- type: 'MFA_SETUP_REQUIRED',
4140
- method: 'totp',
4141
- setupData: { code: '123456' },
4142
- };
4143
-
4144
- try {
4145
- await service.respondToChallenge(createRespondChallengeDto(response));
4146
- fail('Should have thrown NAuthException');
4147
- } catch (error: any) {
4148
- expect(error).toBeInstanceOf(NAuthException);
4149
- expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
4150
- }
4151
- });
4152
- });
4153
-
4154
- describe('Error handling', () => {
4155
- it('should throw NAuthException if challenge session is invalid', async () => {
4156
- mockChallengeService.validateSession.mockRejectedValue(
4157
- new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'Invalid session'),
4158
- );
4159
-
4160
- const response: VerifyEmailResponse = {
4161
- session: 'invalid-session',
4162
- type: 'VERIFY_EMAIL',
4163
- code: '123456',
4164
- };
4165
-
4166
- try {
4167
- await service.respondToChallenge(createRespondChallengeDto(response));
4168
- fail('Should have thrown NAuthException');
4169
- } catch (error: any) {
4170
- expect(error).toBeInstanceOf(NAuthException);
4171
- expect(error.code).toBe(AuthErrorCode.CHALLENGE_INVALID);
4172
- }
4173
- });
4174
-
4175
- it('should throw NAuthException if user not found in challenge session', async () => {
4176
- const sessionWithoutUser = { ...mockChallengeSession, user: null };
4177
- mockChallengeService.validateSession.mockResolvedValue(sessionWithoutUser as any);
4178
-
4179
- const response: VerifyEmailResponse = {
4180
- session: 'session-token',
4181
- type: 'VERIFY_EMAIL',
4182
- code: '123456',
4183
- };
4184
-
4185
- try {
4186
- await service.respondToChallenge(createRespondChallengeDto(response));
4187
- fail('Should have thrown NAuthException');
4188
- } catch (error: any) {
4189
- expect(error).toBeInstanceOf(NAuthException);
4190
- expect(error.code).toBe(AuthErrorCode.CHALLENGE_INVALID);
4191
- }
4192
- });
4193
- });
4194
- });
4195
- });