@nauth-toolkit/core 0.1.0 → 0.1.5

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 +9 -0
  3. package/package.json +8 -3
  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,1363 +0,0 @@
1
- import { Repository, SelectQueryBuilder } from 'typeorm';
2
- import { ChallengeService } from './challenge.service';
3
- import { NAuthException } from '../exceptions/nauth.exception';
4
- import { IUser, IChallengeSession } from '../interfaces/entities.interface';
5
- import { AuthChallenge } from '../dto/auth-challenge.dto';
6
- import { NAuthLogger } from '../utils/nauth-logger';
7
- import { AuthAuditService } from './auth-audit.service';
8
- import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
9
- import { AuthErrorCode } from '../enums/error-codes.enum';
10
- import { BaseChallengeSession } from '../entities';
11
- import { ClientInfoService } from './client-info.service';
12
-
13
- /**
14
- * Challenge Service Unit Tests
15
- *
16
- * Tests challenge session creation, validation, consumption, and cleanup.
17
- * Covers all challenge types, expiration, max attempts, and edge cases.
18
- *
19
- * Platform-agnostic: Uses direct instantiation, no NestJS dependencies.
20
- */
21
- describe('ChallengeService', () => {
22
- let service: ChallengeService;
23
- let mockChallengeSessionRepository: jest.Mocked<Repository<BaseChallengeSession>>;
24
- let mockClientInfoService: jest.Mocked<ClientInfoService>;
25
- let mockAuditService: jest.Mocked<AuthAuditService>;
26
- let mockLogger: jest.Mocked<NAuthLogger>;
27
- let mockQueryBuilder: jest.Mocked<SelectQueryBuilder<BaseChallengeSession>>;
28
-
29
- const mockUser: Partial<IUser> = {
30
- id: 1,
31
- sub: 'user-uuid-123',
32
- email: 'test@example.com',
33
- phone: '+1234567890',
34
- isEmailVerified: false,
35
- isPhoneVerified: false,
36
- };
37
-
38
- const mockChallengeSession: Partial<IChallengeSession> = {
39
- id: 1,
40
- userId: 1,
41
- user: mockUser as IUser,
42
- challengeName: AuthChallenge.VERIFY_EMAIL,
43
- sessionToken: 'session-token-123',
44
- expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes from now
45
- isCompleted: false,
46
- completedAt: null,
47
- attempts: 0,
48
- maxAttempts: 3,
49
- ipAddress: '1.2.3.4',
50
- userAgent: 'test-user-agent',
51
- createdAt: new Date(),
52
- };
53
-
54
- beforeEach(() => {
55
- // Create mock query builder
56
- mockQueryBuilder = {
57
- leftJoinAndSelect: jest.fn().mockReturnThis(),
58
- where: jest.fn().mockReturnThis(),
59
- select: jest.fn().mockReturnThis(),
60
- getOne: jest.fn(),
61
- } as any;
62
-
63
- // Create mock repository
64
- mockChallengeSessionRepository = {
65
- create: jest.fn(),
66
- save: jest.fn(),
67
- delete: jest.fn(),
68
- findOne: jest.fn(),
69
- createQueryBuilder: jest.fn(() => mockQueryBuilder),
70
- } as any;
71
-
72
- // Create mock services
73
-
74
- mockAuditService = {
75
- recordEvent: jest.fn(),
76
- } as any;
77
-
78
- mockLogger = {
79
- log: jest.fn(),
80
- error: jest.fn(),
81
- warn: jest.fn(),
82
- debug: jest.fn(),
83
- } as any;
84
-
85
- mockClientInfoService = {
86
- get: jest.fn().mockReturnValue({
87
- ipAddress: '1.2.3.4',
88
- userAgent: 'test-user-agent',
89
- }),
90
- } as any;
91
-
92
- // Instantiate service directly
93
- service = new ChallengeService(mockChallengeSessionRepository, mockClientInfoService, mockLogger, mockAuditService);
94
- });
95
-
96
- afterEach(() => {
97
- jest.clearAllMocks();
98
- });
99
-
100
- // ============================================================================
101
- // Service Initialization
102
- // ============================================================================
103
-
104
- it('should be defined', () => {
105
- expect(service).toBeDefined();
106
- });
107
-
108
- // ============================================================================
109
- // createChallengeSession() Method
110
- // ============================================================================
111
-
112
- describe('createChallengeSession', () => {
113
- it('should create a challenge session successfully', async () => {
114
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
115
- mockChallengeSessionRepository.create.mockReturnValue(mockChallengeSession as any);
116
- mockChallengeSessionRepository.save.mockResolvedValue(mockChallengeSession as any);
117
- mockAuditService.recordEvent.mockResolvedValue({} as any);
118
-
119
- const result = await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL, {
120
- email: mockUser.email,
121
- });
122
-
123
- expect(result).toBeDefined();
124
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
125
- expect(mockChallengeSessionRepository.create).toHaveBeenCalled();
126
- expect(mockChallengeSessionRepository.save).toHaveBeenCalled();
127
- expect(mockLogger.log).toHaveBeenCalled();
128
- });
129
-
130
- it('should cleanup expired sessions before creating new one', async () => {
131
- const deleteSpy = mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 2 } as any);
132
- mockChallengeSessionRepository.create.mockReturnValue(mockChallengeSession as any);
133
- mockChallengeSessionRepository.save.mockResolvedValue(mockChallengeSession as any);
134
- mockAuditService.recordEvent.mockResolvedValue({} as any);
135
-
136
- await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL);
137
-
138
- expect(deleteSpy).toHaveBeenCalledTimes(2); // Once for expired, once for completed
139
- });
140
-
141
- it('should throttle cleanup to once per 5 minutes per user', async () => {
142
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
143
- mockChallengeSessionRepository.create.mockReturnValue(mockChallengeSession as any);
144
- mockChallengeSessionRepository.save.mockResolvedValue(mockChallengeSession as any);
145
- mockAuditService.recordEvent.mockResolvedValue({} as any);
146
-
147
- // Create first session
148
- await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL);
149
- const firstDeleteCount = mockChallengeSessionRepository.delete.mock.calls.length;
150
-
151
- // Create second session immediately (should not trigger cleanup again)
152
- await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_PHONE);
153
- const secondDeleteCount = mockChallengeSessionRepository.delete.mock.calls.length;
154
-
155
- // Cleanup should only run once (first call)
156
- expect(secondDeleteCount).toBe(firstDeleteCount);
157
- });
158
-
159
- it('should create session with default expiration (15 minutes)', async () => {
160
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
161
- const createdSession = { ...mockChallengeSession, expiresAt: new Date() } as any;
162
- mockChallengeSessionRepository.create.mockReturnValue(createdSession);
163
- mockChallengeSessionRepository.save.mockResolvedValue(createdSession);
164
- mockAuditService.recordEvent.mockResolvedValue({} as any);
165
-
166
- jest.useFakeTimers();
167
- const now = Date.now();
168
- jest.setSystemTime(now);
169
-
170
- await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL);
171
-
172
- expect(mockChallengeSessionRepository.create).toHaveBeenCalledWith(
173
- (expect as any).objectContaining({
174
- expiresAt: (expect as any).any(Date),
175
- }),
176
- );
177
-
178
- const createdCall = mockChallengeSessionRepository.create.mock.calls[0][0] as any;
179
- const expectedExpiry = new Date(now + 15 * 60 * 1000);
180
- if (createdCall?.expiresAt) {
181
- expect(createdCall.expiresAt.getTime()).toBeCloseTo(expectedExpiry.getTime(), -2); // Within 100ms
182
- }
183
-
184
- jest.useRealTimers();
185
- });
186
-
187
- it('should create session with provided metadata', async () => {
188
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
189
- mockChallengeSessionRepository.create.mockReturnValue(mockChallengeSession as any);
190
- mockChallengeSessionRepository.save.mockResolvedValue(mockChallengeSession as any);
191
- mockAuditService.recordEvent.mockResolvedValue({} as any);
192
-
193
- const metadata = { email: 'test@example.com', verificationTokenId: 123 };
194
-
195
- await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL, metadata);
196
-
197
- expect(mockChallengeSessionRepository.create).toHaveBeenCalledWith(
198
- (expect as any).objectContaining({
199
- metadata,
200
- }),
201
- );
202
- });
203
-
204
- it('should create session with provided IP and user agent', async () => {
205
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
206
- mockChallengeSessionRepository.create.mockReturnValue(mockChallengeSession as any);
207
- mockChallengeSessionRepository.save.mockResolvedValue(mockChallengeSession as any);
208
- mockAuditService.recordEvent.mockResolvedValue({} as any);
209
-
210
- mockClientInfoService.get.mockReturnValue({
211
- ipAddress: '192.168.1.1',
212
- userAgent: 'Custom-Agent',
213
- });
214
-
215
- await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL, undefined);
216
-
217
- expect(mockChallengeSessionRepository.create).toHaveBeenCalledWith(
218
- (expect as any).objectContaining({
219
- ipAddress: '192.168.1.1',
220
- userAgent: 'Custom-Agent',
221
- }),
222
- );
223
- });
224
-
225
- it('should record audit event on session creation', async () => {
226
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
227
- const sessionWithId = { ...mockChallengeSession, id: 1 } as any;
228
- mockChallengeSessionRepository.create.mockReturnValue(sessionWithId);
229
- mockChallengeSessionRepository.save.mockResolvedValue(sessionWithId);
230
- mockAuditService.recordEvent.mockResolvedValue({} as any);
231
-
232
- await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL);
233
-
234
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
235
- (expect as any).objectContaining({
236
- eventType: AuthAuditEventType.CHALLENGE_CREATED,
237
- eventStatus: 'INFO',
238
- userId: mockUser.id,
239
- }),
240
- );
241
- });
242
-
243
- it('should handle audit service errors gracefully', async () => {
244
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
245
- mockChallengeSessionRepository.create.mockReturnValue(mockChallengeSession as any);
246
- mockChallengeSessionRepository.save.mockResolvedValue(mockChallengeSession as any);
247
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit service error'));
248
-
249
- const result = await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL);
250
-
251
- expect(result).toBeDefined();
252
- expect(mockLogger.error).toHaveBeenCalled();
253
- });
254
-
255
- it('should handle non-Error audit exceptions', async () => {
256
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
257
- mockChallengeSessionRepository.create.mockReturnValue(mockChallengeSession as any);
258
- mockChallengeSessionRepository.save.mockResolvedValue(mockChallengeSession as any);
259
- mockAuditService.recordEvent.mockRejectedValue('String error' as any);
260
-
261
- const result = await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL);
262
-
263
- expect(result).toBeDefined();
264
- expect(mockLogger.error).toHaveBeenCalledWith(
265
- (expect as any).stringContaining('Failed to record CHALLENGE_CREATED audit event: Unknown error'),
266
- (expect as any).any(Object),
267
- );
268
- });
269
-
270
- it('should reuse existing active challenge session (deduplication)', async () => {
271
- const existingSession = {
272
- ...mockChallengeSession,
273
- id: 123,
274
- sessionToken: 'existing-token-456',
275
- expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes from now (not expired)
276
- isCompleted: false,
277
- user: mockUser,
278
- };
279
-
280
- // Mock finding an existing active session
281
- mockChallengeSessionRepository.findOne.mockResolvedValue(existingSession as any);
282
-
283
- const result = await service.createChallengeSession(mockUser as IUser, AuthChallenge.MFA_SETUP_REQUIRED);
284
-
285
- // Should return existing session
286
- expect(result.sessionToken).toBe('existing-token-456');
287
- expect(result.id).toBe(123);
288
-
289
- // Should NOT create a new session
290
- expect(mockChallengeSessionRepository.create).not.toHaveBeenCalled();
291
- expect(mockChallengeSessionRepository.save).not.toHaveBeenCalled();
292
-
293
- // Should NOT record a duplicate CHALLENGE_CREATED audit event
294
- expect(mockAuditService.recordEvent).not.toHaveBeenCalled();
295
- });
296
-
297
- it('should create new session if existing session is expired', async () => {
298
- const expiredSession = {
299
- ...mockChallengeSession,
300
- id: 123,
301
- sessionToken: 'expired-token',
302
- expiresAt: new Date(Date.now() - 1000), // Expired 1 second ago
303
- isCompleted: false,
304
- user: mockUser,
305
- };
306
-
307
- // Mock finding an expired session
308
- mockChallengeSessionRepository.findOne.mockResolvedValue(expiredSession as any);
309
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 1 } as any);
310
- mockChallengeSessionRepository.create.mockReturnValue(mockChallengeSession as any);
311
- mockChallengeSessionRepository.save.mockResolvedValue({ ...mockChallengeSession, id: 124 } as any);
312
- mockAuditService.recordEvent.mockResolvedValue({} as any);
313
-
314
- const result = await service.createChallengeSession(mockUser as IUser, AuthChallenge.MFA_SETUP_REQUIRED);
315
-
316
- // Should delete expired session
317
- expect(mockChallengeSessionRepository.delete).toHaveBeenCalledWith({ id: 123 });
318
-
319
- // Should create a new session
320
- expect(mockChallengeSessionRepository.create).toHaveBeenCalled();
321
- expect(mockChallengeSessionRepository.save).toHaveBeenCalled();
322
-
323
- // Should record new CHALLENGE_CREATED audit event
324
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
325
- (expect as any).objectContaining({
326
- eventType: AuthAuditEventType.CHALLENGE_CREATED,
327
- }),
328
- );
329
- });
330
-
331
- it('should create new session if no existing session found', async () => {
332
- // Mock no existing session
333
- mockChallengeSessionRepository.findOne.mockResolvedValue(null);
334
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
335
- mockChallengeSessionRepository.create.mockReturnValue(mockChallengeSession as any);
336
- mockChallengeSessionRepository.save.mockResolvedValue({ ...mockChallengeSession, id: 125 } as any);
337
- mockAuditService.recordEvent.mockResolvedValue({} as any);
338
-
339
- const result = await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL);
340
-
341
- // Should create a new session
342
- expect(mockChallengeSessionRepository.create).toHaveBeenCalled();
343
- expect(mockChallengeSessionRepository.save).toHaveBeenCalled();
344
-
345
- // Should record CHALLENGE_CREATED audit event
346
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
347
- (expect as any).objectContaining({
348
- eventType: AuthAuditEventType.CHALLENGE_CREATED,
349
- }),
350
- );
351
- });
352
-
353
- it('should use client info from ClientInfoService in audit event', async () => {
354
- mockChallengeSessionRepository.findOne.mockResolvedValue(null);
355
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
356
- const sessionWithId = { ...mockChallengeSession, id: 1 } as any;
357
- mockChallengeSessionRepository.create.mockReturnValue(sessionWithId);
358
- mockChallengeSessionRepository.save.mockResolvedValue(sessionWithId);
359
- mockAuditService.recordEvent.mockResolvedValue({} as any);
360
-
361
- mockClientInfoService.get.mockReturnValue({
362
- ipAddress: '192.168.1.100',
363
- userAgent: 'Custom-Agent-String',
364
- });
365
-
366
- await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL, undefined);
367
-
368
- // Audit service now gets IP and userAgent from ClientInfoService automatically
369
- // Verify that recordEvent was called (the audit service will extract client info from context)
370
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
371
- (expect as any).objectContaining({
372
- userId: mockUser.id,
373
- eventType: AuthAuditEventType.CHALLENGE_CREATED,
374
- eventStatus: 'INFO',
375
- }),
376
- );
377
- // Verify that ClientInfoService was called to get client info
378
- expect(mockClientInfoService.get).toHaveBeenCalled();
379
- });
380
-
381
- it('should create MFA challenge sessions with metadata', async () => {
382
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
383
- const mfaSession = {
384
- ...mockChallengeSession,
385
- challengeName: AuthChallenge.MFA_REQUIRED,
386
- } as any;
387
- mockChallengeSessionRepository.create.mockReturnValue(mfaSession);
388
- mockChallengeSessionRepository.save.mockResolvedValue(mfaSession);
389
- mockAuditService.recordEvent.mockResolvedValue({} as any);
390
-
391
- const mfaMetadata = {
392
- deviceId: 'device-123',
393
- method: 'TOTP',
394
- availableMethods: ['TOTP', 'SMS'],
395
- };
396
-
397
- const result = await service.createChallengeSession(mockUser as IUser, AuthChallenge.MFA_REQUIRED, mfaMetadata);
398
-
399
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
400
- expect(mockChallengeSessionRepository.create).toHaveBeenCalledWith(
401
- (expect as any).objectContaining({
402
- challengeName: AuthChallenge.MFA_REQUIRED,
403
- metadata: mfaMetadata,
404
- }),
405
- );
406
- });
407
-
408
- it('should create MFA_SETUP_REQUIRED challenge sessions', async () => {
409
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
410
- const mfaSetupSession = {
411
- ...mockChallengeSession,
412
- challengeName: AuthChallenge.MFA_SETUP_REQUIRED,
413
- } as any;
414
- mockChallengeSessionRepository.create.mockReturnValue(mfaSetupSession);
415
- mockChallengeSessionRepository.save.mockResolvedValue(mfaSetupSession);
416
- mockAuditService.recordEvent.mockResolvedValue({} as any);
417
-
418
- const setupMetadata = {
419
- allowedMethods: ['TOTP', 'SMS', 'EMAIL'],
420
- gracePeriodExpired: true,
421
- };
422
-
423
- const result = await service.createChallengeSession(
424
- mockUser as IUser,
425
- AuthChallenge.MFA_SETUP_REQUIRED,
426
- setupMetadata,
427
- );
428
-
429
- expect(result.challengeName).toBe(AuthChallenge.MFA_SETUP_REQUIRED);
430
- expect(mockChallengeSessionRepository.create).toHaveBeenCalledWith(
431
- (expect as any).objectContaining({
432
- challengeName: AuthChallenge.MFA_SETUP_REQUIRED,
433
- metadata: setupMetadata,
434
- }),
435
- );
436
- });
437
-
438
- it('should create FORCE_CHANGE_PASSWORD challenge sessions', async () => {
439
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
440
- const passwordChangeSession = {
441
- ...mockChallengeSession,
442
- challengeName: AuthChallenge.FORCE_CHANGE_PASSWORD,
443
- } as any;
444
- mockChallengeSessionRepository.create.mockReturnValue(passwordChangeSession);
445
- mockChallengeSessionRepository.save.mockResolvedValue(passwordChangeSession);
446
- mockAuditService.recordEvent.mockResolvedValue({} as any);
447
-
448
- const passwordChangeMetadata = {
449
- reason: 'admin_forced',
450
- passwordExpired: true,
451
- instructions: 'You must change your password before continuing',
452
- };
453
-
454
- const result = await service.createChallengeSession(
455
- mockUser as IUser,
456
- AuthChallenge.FORCE_CHANGE_PASSWORD,
457
- passwordChangeMetadata,
458
- );
459
-
460
- expect(result.challengeName).toBe(AuthChallenge.FORCE_CHANGE_PASSWORD);
461
- expect(mockChallengeSessionRepository.create).toHaveBeenCalledWith(
462
- (expect as any).objectContaining({
463
- challengeName: AuthChallenge.FORCE_CHANGE_PASSWORD,
464
- metadata: passwordChangeMetadata,
465
- }),
466
- );
467
- });
468
-
469
- // VERIFY_EMAIL_AND_PHONE removed - challenges are sequential (VERIFY_EMAIL first, then VERIFY_PHONE)
470
- // This test is no longer needed as the challenge system works sequentially
471
-
472
- it('should trigger cleanup after 5 minutes have passed', async () => {
473
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
474
- mockChallengeSessionRepository.create.mockReturnValue(mockChallengeSession as any);
475
- mockChallengeSessionRepository.save.mockResolvedValue(mockChallengeSession as any);
476
- mockAuditService.recordEvent.mockResolvedValue({} as any);
477
-
478
- jest.useFakeTimers();
479
- const now = Date.now();
480
- jest.setSystemTime(now);
481
-
482
- // Create first session
483
- await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL);
484
- const firstDeleteCount = mockChallengeSessionRepository.delete.mock.calls.length;
485
-
486
- // Advance time by 5 minutes and 1 second
487
- jest.setSystemTime(now + 5 * 60 * 1000 + 1000);
488
-
489
- // Create second session (should trigger cleanup again)
490
- await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_PHONE);
491
- const secondDeleteCount = mockChallengeSessionRepository.delete.mock.calls.length;
492
-
493
- // Cleanup should run again after 5 minutes
494
- expect(secondDeleteCount).toBeGreaterThan(firstDeleteCount);
495
-
496
- jest.useRealTimers();
497
- });
498
-
499
- it('should create session for all challenge types', async () => {
500
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
501
- mockChallengeSessionRepository.create.mockReturnValue(mockChallengeSession as any);
502
- mockChallengeSessionRepository.save.mockResolvedValue(mockChallengeSession as any);
503
- mockAuditService.recordEvent.mockResolvedValue({} as any);
504
-
505
- const challengeTypes = [
506
- AuthChallenge.VERIFY_EMAIL,
507
- AuthChallenge.VERIFY_PHONE,
508
- AuthChallenge.MFA_REQUIRED,
509
- AuthChallenge.MFA_SETUP_REQUIRED,
510
- AuthChallenge.FORCE_CHANGE_PASSWORD,
511
- ];
512
-
513
- for (const challengeType of challengeTypes) {
514
- await service.createChallengeSession(mockUser as IUser, challengeType);
515
- expect(mockChallengeSessionRepository.create).toHaveBeenCalledWith(
516
- (expect as any).objectContaining({
517
- challengeName: challengeType,
518
- }),
519
- );
520
- jest.clearAllMocks();
521
- }
522
- });
523
- });
524
-
525
- // ============================================================================
526
- // validateSession() Method
527
- // ============================================================================
528
-
529
- describe('validateSession', () => {
530
- it('should validate a valid session', async () => {
531
- const validSession = {
532
- ...mockChallengeSession,
533
- expiresAt: new Date(Date.now() + 60000), // 1 minute from now
534
- };
535
- mockChallengeSessionRepository.findOne.mockResolvedValue(validSession as any);
536
-
537
- const result = await service.validateSession('session-token-123', AuthChallenge.VERIFY_EMAIL);
538
-
539
- expect(result).toBeDefined();
540
- expect(result.sessionToken).toBe('session-token-123');
541
- expect(mockChallengeSessionRepository.findOne).toHaveBeenCalledWith({
542
- where: { sessionToken: 'session-token-123' },
543
- relations: ['user'],
544
- });
545
- });
546
-
547
- it('should throw NAuthException if session not found', async () => {
548
- mockChallengeSessionRepository.findOne.mockResolvedValue(null);
549
-
550
- try {
551
- await service.validateSession('invalid-token');
552
- fail('Should have thrown NAuthException');
553
- } catch (error) {
554
- expect(error).toBeInstanceOf(NAuthException);
555
- expect((error as NAuthException).code).toBe(AuthErrorCode.CHALLENGE_INVALID);
556
- expect(mockLogger.warn).toHaveBeenCalled();
557
- }
558
- });
559
-
560
- it('should throw NAuthException if session expired', async () => {
561
- const expiredSession = {
562
- ...mockChallengeSession,
563
- expiresAt: new Date(Date.now() - 1000), // 1 second ago
564
- };
565
- mockChallengeSessionRepository.findOne.mockResolvedValue(expiredSession as any);
566
-
567
- try {
568
- await service.validateSession('session-token-123');
569
- fail('Should have thrown NAuthException');
570
- } catch (error) {
571
- expect(error).toBeInstanceOf(NAuthException);
572
- expect((error as NAuthException).code).toBe(AuthErrorCode.CHALLENGE_EXPIRED);
573
- expect((error as NAuthException).message).toContain('expired');
574
- expect(mockLogger.warn).toHaveBeenCalled();
575
- }
576
- });
577
-
578
- it('should throw NAuthException if session expires exactly at current time', async () => {
579
- jest.useFakeTimers();
580
- const now = new Date();
581
- jest.setSystemTime(now);
582
-
583
- const exactlyExpiredSession = {
584
- ...mockChallengeSession,
585
- expiresAt: new Date(now.getTime() - 1), // 1ms ago
586
- };
587
- mockChallengeSessionRepository.findOne.mockResolvedValue(exactlyExpiredSession as any);
588
-
589
- try {
590
- await service.validateSession('session-token-123');
591
- fail('Should have thrown NAuthException');
592
- } catch (error) {
593
- expect(error).toBeInstanceOf(NAuthException);
594
- expect((error as NAuthException).code).toBe(AuthErrorCode.CHALLENGE_EXPIRED);
595
- }
596
-
597
- jest.useRealTimers();
598
- });
599
-
600
- it('should throw NAuthException if session already completed', async () => {
601
- const completedSession = {
602
- ...mockChallengeSession,
603
- isCompleted: true,
604
- };
605
- mockChallengeSessionRepository.findOne.mockResolvedValue(completedSession as any);
606
-
607
- try {
608
- await service.validateSession('session-token-123');
609
- fail('Should have thrown NAuthException');
610
- } catch (error) {
611
- expect(error).toBeInstanceOf(NAuthException);
612
- expect((error as NAuthException).code).toBe(AuthErrorCode.CHALLENGE_ALREADY_COMPLETED);
613
- expect((error as NAuthException).message).toContain('already been completed');
614
- expect(mockLogger.warn).toHaveBeenCalled();
615
- }
616
- });
617
-
618
- it('should throw NAuthException if max attempts exceeded', async () => {
619
- const maxAttemptsSession = {
620
- ...mockChallengeSession,
621
- attempts: 3,
622
- maxAttempts: 3,
623
- };
624
- mockChallengeSessionRepository.findOne.mockResolvedValue(maxAttemptsSession as any);
625
-
626
- try {
627
- await service.validateSession('session-token-123');
628
- fail('Should have thrown NAuthException');
629
- } catch (error) {
630
- expect(error).toBeInstanceOf(NAuthException);
631
- expect((error as NAuthException).code).toBe(AuthErrorCode.CHALLENGE_MAX_ATTEMPTS);
632
- expect((error as NAuthException).message).toContain('Maximum challenge attempts exceeded');
633
- expect(mockLogger.warn).toHaveBeenCalled();
634
- }
635
- });
636
-
637
- it('should throw NAuthException if attempts exceed max attempts', async () => {
638
- const overMaxAttemptsSession = {
639
- ...mockChallengeSession,
640
- attempts: 4,
641
- maxAttempts: 3,
642
- };
643
- mockChallengeSessionRepository.findOne.mockResolvedValue(overMaxAttemptsSession as any);
644
-
645
- try {
646
- await service.validateSession('session-token-123');
647
- fail('Should have thrown NAuthException');
648
- } catch (error) {
649
- expect(error).toBeInstanceOf(NAuthException);
650
- expect((error as NAuthException).code).toBe(AuthErrorCode.CHALLENGE_MAX_ATTEMPTS);
651
- }
652
- });
653
-
654
- it('should validate session when attempts are just below max', async () => {
655
- const validSession = {
656
- ...mockChallengeSession,
657
- expiresAt: new Date(Date.now() + 60000),
658
- attempts: 2,
659
- maxAttempts: 3,
660
- };
661
- mockChallengeSessionRepository.findOne.mockResolvedValue(validSession as any);
662
-
663
- const result = await service.validateSession('session-token-123');
664
-
665
- expect(result).toBeDefined();
666
- expect(result.attempts).toBe(2);
667
- });
668
-
669
- it('should throw NAuthException if challenge type mismatch', async () => {
670
- mockChallengeSessionRepository.findOne.mockResolvedValue(mockChallengeSession as any);
671
-
672
- try {
673
- await service.validateSession('session-token-123', AuthChallenge.VERIFY_PHONE);
674
- fail('Should have thrown NAuthException');
675
- } catch (error) {
676
- expect(error).toBeInstanceOf(NAuthException);
677
- expect((error as NAuthException).code).toBe(AuthErrorCode.CHALLENGE_TYPE_MISMATCH);
678
- expect((error as NAuthException).message).toContain('Invalid challenge type');
679
- expect(mockLogger.warn).toHaveBeenCalled();
680
- }
681
- });
682
-
683
- it('should validate session without expected challenge type', async () => {
684
- const validSession = {
685
- ...mockChallengeSession,
686
- expiresAt: new Date(Date.now() + 60000),
687
- };
688
- mockChallengeSessionRepository.findOne.mockResolvedValue(validSession as any);
689
-
690
- const result = await service.validateSession('session-token-123');
691
-
692
- expect(result).toBeDefined();
693
- expect(result.sessionToken).toBe('session-token-123');
694
- });
695
-
696
- it('should load session with user relation', async () => {
697
- const validSession = {
698
- ...mockChallengeSession,
699
- expiresAt: new Date(Date.now() + 60000),
700
- };
701
- mockChallengeSessionRepository.findOne.mockResolvedValue(validSession as any);
702
-
703
- await service.validateSession('session-token-123');
704
-
705
- // Verify findOne is called with relations to load user
706
- expect(mockChallengeSessionRepository.findOne).toHaveBeenCalledWith({
707
- where: { sessionToken: 'session-token-123' },
708
- relations: ['user'],
709
- });
710
- });
711
- });
712
-
713
- // ============================================================================
714
- // incrementAttempts() Method
715
- // ============================================================================
716
-
717
- describe('incrementAttempts', () => {
718
- it('should increment attempt counter', async () => {
719
- const session = { ...mockChallengeSession, attempts: 1 } as IChallengeSession;
720
- const updatedSession = { ...session, attempts: 2 };
721
- mockChallengeSessionRepository.save.mockResolvedValue(updatedSession as any);
722
-
723
- const result = await service.incrementAttempts(session);
724
-
725
- expect(result.attempts).toBe(2);
726
- expect(mockChallengeSessionRepository.save).toHaveBeenCalledWith(session);
727
- });
728
-
729
- it('should record audit event when max attempts exceeded', async () => {
730
- const session = {
731
- ...mockChallengeSession,
732
- attempts: 2,
733
- maxAttempts: 3,
734
- } as IChallengeSession;
735
- const updatedSession = { ...session, attempts: 3 };
736
- mockChallengeSessionRepository.save.mockResolvedValue(updatedSession as any);
737
- mockAuditService.recordEvent.mockResolvedValue({} as any);
738
-
739
- await service.incrementAttempts(session);
740
-
741
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
742
- (expect as any).objectContaining({
743
- eventType: AuthAuditEventType.CHALLENGE_ATTEMPT_FAILED,
744
- eventStatus: 'FAILURE',
745
- reason: 'max_attempts_exceeded',
746
- }),
747
- );
748
- });
749
-
750
- it('should not record audit event when max attempts not exceeded', async () => {
751
- const session = {
752
- ...mockChallengeSession,
753
- attempts: 1,
754
- maxAttempts: 3,
755
- } as IChallengeSession;
756
- const updatedSession = { ...session, attempts: 2 };
757
- mockChallengeSessionRepository.save.mockResolvedValue(updatedSession as any);
758
-
759
- await service.incrementAttempts(session);
760
-
761
- expect(mockAuditService.recordEvent).not.toHaveBeenCalled();
762
- });
763
-
764
- it('should handle audit service errors gracefully', async () => {
765
- const session = {
766
- ...mockChallengeSession,
767
- attempts: 2,
768
- maxAttempts: 3,
769
- } as IChallengeSession;
770
- const updatedSession = { ...session, attempts: 3 };
771
- mockChallengeSessionRepository.save.mockResolvedValue(updatedSession as any);
772
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
773
-
774
- const result = await service.incrementAttempts(session);
775
-
776
- expect(result.attempts).toBe(3);
777
- expect(mockLogger.error).toHaveBeenCalled();
778
- });
779
-
780
- it('should handle non-Error audit exceptions in incrementAttempts', async () => {
781
- const session = {
782
- ...mockChallengeSession,
783
- attempts: 2,
784
- maxAttempts: 3,
785
- } as IChallengeSession;
786
- const updatedSession = { ...session, attempts: 3 };
787
- mockChallengeSessionRepository.save.mockResolvedValue(updatedSession as any);
788
- mockAuditService.recordEvent.mockRejectedValue('String error' as any);
789
-
790
- const result = await service.incrementAttempts(session);
791
-
792
- expect(result.attempts).toBe(3);
793
- expect(mockLogger.error).toHaveBeenCalledWith(
794
- (expect as any).stringContaining('Failed to record CHALLENGE_ATTEMPT_FAILED audit event: Unknown error'),
795
- (expect as any).any(Object),
796
- );
797
- });
798
-
799
- it('should record audit event with session IP and userAgent when available', async () => {
800
- const session = {
801
- ...mockChallengeSession,
802
- attempts: 2,
803
- maxAttempts: 3,
804
- ipAddress: '10.20.30.40',
805
- userAgent: 'custom-browser-agent',
806
- } as IChallengeSession;
807
- const updatedSession = { ...session, attempts: 3 };
808
- mockChallengeSessionRepository.save.mockResolvedValue(updatedSession as any);
809
- mockAuditService.recordEvent.mockResolvedValue({} as any);
810
-
811
- await service.incrementAttempts(session);
812
-
813
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
814
- (expect as any).objectContaining({
815
- ipAddress: '10.20.30.40',
816
- userAgent: 'custom-browser-agent',
817
- }),
818
- );
819
- });
820
-
821
- it('should record audit event with undefined IP/userAgent when session values are null', async () => {
822
- const session = {
823
- ...mockChallengeSession,
824
- attempts: 2,
825
- maxAttempts: 3,
826
- ipAddress: null,
827
- userAgent: null,
828
- } as IChallengeSession;
829
- const updatedSession = { ...session, attempts: 3 };
830
- mockChallengeSessionRepository.save.mockResolvedValue(updatedSession as any);
831
- mockAuditService.recordEvent.mockResolvedValue({} as any);
832
-
833
- await service.incrementAttempts(session);
834
-
835
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
836
- (expect as any).objectContaining({
837
- ipAddress: undefined,
838
- userAgent: undefined,
839
- }),
840
- );
841
- });
842
-
843
- it('should handle audit logging when incrementAttempts reaches exactly max attempts', async () => {
844
- const session = {
845
- ...mockChallengeSession,
846
- attempts: 2,
847
- maxAttempts: 3,
848
- } as IChallengeSession;
849
- const updatedSession = { ...session, attempts: 3 };
850
- mockChallengeSessionRepository.save.mockResolvedValue(updatedSession as any);
851
- mockAuditService.recordEvent.mockResolvedValue({} as any);
852
-
853
- await service.incrementAttempts(session);
854
-
855
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
856
- (expect as any).objectContaining({
857
- eventType: AuthAuditEventType.CHALLENGE_ATTEMPT_FAILED,
858
- description: (expect as any).stringContaining('maximum attempts (3) exceeded'),
859
- }),
860
- );
861
- });
862
-
863
- it('should handle session without user gracefully', async () => {
864
- const sessionWithoutUser = {
865
- ...mockChallengeSession,
866
- attempts: 2,
867
- maxAttempts: 3,
868
- user: undefined,
869
- } as any;
870
- const updatedSession = { ...sessionWithoutUser, attempts: 3 };
871
- mockChallengeSessionRepository.save.mockResolvedValue(updatedSession);
872
-
873
- await service.incrementAttempts(sessionWithoutUser);
874
-
875
- // Should not throw, but audit may not be recorded
876
- expect(mockChallengeSessionRepository.save).toHaveBeenCalled();
877
- });
878
- });
879
-
880
- // ============================================================================
881
- // validateAndConsumeSession() Method
882
- // ============================================================================
883
-
884
- describe('validateAndConsumeSession', () => {
885
- it('should validate and mark session as completed', async () => {
886
- const validSession = {
887
- ...mockChallengeSession,
888
- expiresAt: new Date(Date.now() + 60000),
889
- };
890
- mockChallengeSessionRepository.findOne.mockResolvedValue(validSession as any);
891
- const completedSession = { ...validSession, isCompleted: true, completedAt: new Date() };
892
- mockChallengeSessionRepository.save.mockResolvedValue(completedSession as any);
893
- mockAuditService.recordEvent.mockResolvedValue({} as any);
894
-
895
- const result = await service.validateAndConsumeSession('session-token-123', AuthChallenge.VERIFY_EMAIL);
896
-
897
- expect(result.isCompleted).toBe(true);
898
- expect(result.completedAt).toBeDefined();
899
- expect(mockChallengeSessionRepository.save).toHaveBeenCalled();
900
- expect(mockLogger.log).toHaveBeenCalled();
901
- });
902
-
903
- it('should record audit event on session completion', async () => {
904
- const validSession = {
905
- ...mockChallengeSession,
906
- expiresAt: new Date(Date.now() + 60000),
907
- };
908
- mockChallengeSessionRepository.findOne.mockResolvedValue(validSession as any);
909
- const completedSession = { ...validSession, isCompleted: true };
910
- mockChallengeSessionRepository.save.mockResolvedValue(completedSession as any);
911
- mockAuditService.recordEvent.mockResolvedValue({} as any);
912
-
913
- await service.validateAndConsumeSession('session-token-123', AuthChallenge.VERIFY_EMAIL);
914
-
915
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
916
- (expect as any).objectContaining({
917
- eventType: AuthAuditEventType.CHALLENGE_COMPLETED,
918
- eventStatus: 'SUCCESS',
919
- userId: mockUser.id,
920
- }),
921
- );
922
- });
923
-
924
- it('should handle audit service errors gracefully', async () => {
925
- const validSession = {
926
- ...mockChallengeSession,
927
- expiresAt: new Date(Date.now() + 60000),
928
- };
929
- mockChallengeSessionRepository.findOne.mockResolvedValue(validSession as any);
930
- const completedSession = { ...validSession, isCompleted: true };
931
- mockChallengeSessionRepository.save.mockResolvedValue(completedSession as any);
932
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
933
-
934
- const result = await service.validateAndConsumeSession('session-token-123', AuthChallenge.VERIFY_EMAIL);
935
-
936
- expect(result.isCompleted).toBe(true);
937
- expect(mockLogger.error).toHaveBeenCalled();
938
- });
939
-
940
- it('should handle non-Error audit exceptions in validateAndConsumeSession', async () => {
941
- const validSession = {
942
- ...mockChallengeSession,
943
- expiresAt: new Date(Date.now() + 60000),
944
- };
945
- mockChallengeSessionRepository.findOne.mockResolvedValue(validSession as any);
946
- const completedSession = { ...validSession, isCompleted: true };
947
- mockChallengeSessionRepository.save.mockResolvedValue(completedSession as any);
948
- mockAuditService.recordEvent.mockRejectedValue('String error' as any);
949
-
950
- const result = await service.validateAndConsumeSession('session-token-123', AuthChallenge.VERIFY_EMAIL);
951
-
952
- expect(result.isCompleted).toBe(true);
953
- expect(mockLogger.error).toHaveBeenCalledWith(
954
- (expect as any).stringContaining('Failed to record CHALLENGE_COMPLETED audit event: Unknown error'),
955
- (expect as any).any(Object),
956
- );
957
- });
958
-
959
- it('should handle validateAndConsumeSession with null session IP and userAgent', async () => {
960
- const validSession = {
961
- ...mockChallengeSession,
962
- expiresAt: new Date(Date.now() + 60000),
963
- ipAddress: null,
964
- userAgent: null,
965
- };
966
- mockChallengeSessionRepository.findOne.mockResolvedValue(validSession as any);
967
- const completedSession = { ...validSession, isCompleted: true };
968
- mockChallengeSessionRepository.save.mockResolvedValue(completedSession as any);
969
- mockAuditService.recordEvent.mockResolvedValue({} as any);
970
-
971
- await service.validateAndConsumeSession('session-token-123', AuthChallenge.VERIFY_EMAIL);
972
-
973
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
974
- (expect as any).objectContaining({
975
- ipAddress: undefined,
976
- userAgent: undefined,
977
- }),
978
- );
979
- });
980
-
981
- it('should throw if validation fails', async () => {
982
- mockChallengeSessionRepository.findOne.mockResolvedValue(null);
983
-
984
- try {
985
- await service.validateAndConsumeSession('invalid-token', AuthChallenge.VERIFY_EMAIL);
986
- fail('Should have thrown NAuthException');
987
- } catch (error) {
988
- expect(error).toBeInstanceOf(NAuthException);
989
- expect(mockChallengeSessionRepository.save).not.toHaveBeenCalled();
990
- }
991
- });
992
-
993
- it('should use session IP and user agent in audit event', async () => {
994
- const validSession = {
995
- ...mockChallengeSession,
996
- expiresAt: new Date(Date.now() + 60000),
997
- ipAddress: '5.6.7.8',
998
- userAgent: 'session-agent',
999
- };
1000
- mockChallengeSessionRepository.findOne.mockResolvedValue(validSession as any);
1001
- const completedSession = { ...validSession, isCompleted: true };
1002
- mockChallengeSessionRepository.save.mockResolvedValue(completedSession as any);
1003
- mockAuditService.recordEvent.mockResolvedValue({} as any);
1004
-
1005
- await service.validateAndConsumeSession('session-token-123', AuthChallenge.VERIFY_EMAIL);
1006
-
1007
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
1008
- (expect as any).objectContaining({
1009
- ipAddress: '5.6.7.8',
1010
- userAgent: 'session-agent',
1011
- }),
1012
- );
1013
- });
1014
- });
1015
-
1016
- // ============================================================================
1017
- // cleanupExpiredSessions() Method
1018
- // ============================================================================
1019
-
1020
- describe('cleanupExpiredSessions', () => {
1021
- it('should delete expired and completed sessions', async () => {
1022
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 5 } as any);
1023
-
1024
- await service.cleanupExpiredSessions(1);
1025
-
1026
- expect(mockChallengeSessionRepository.delete).toHaveBeenCalledTimes(2); // Once for expired, once for completed
1027
- expect(mockChallengeSessionRepository.delete).toHaveBeenCalledWith(
1028
- (expect as any).objectContaining({
1029
- userId: 1,
1030
- }),
1031
- );
1032
- expect(mockChallengeSessionRepository.delete).toHaveBeenCalledWith(
1033
- (expect as any).objectContaining({
1034
- userId: 1,
1035
- isCompleted: true,
1036
- }),
1037
- );
1038
- });
1039
-
1040
- it('should handle cleanup with no sessions to delete', async () => {
1041
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
1042
-
1043
- await service.cleanupExpiredSessions(1);
1044
-
1045
- expect(mockChallengeSessionRepository.delete).toHaveBeenCalledTimes(2);
1046
- });
1047
-
1048
- it('should use LessThan for expiration check', async () => {
1049
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
1050
-
1051
- await service.cleanupExpiredSessions(1);
1052
-
1053
- // LessThan is a TypeORM operator - we can't easily test it, but we verify the call was made
1054
- expect(mockChallengeSessionRepository.delete).toHaveBeenCalled();
1055
- });
1056
- });
1057
-
1058
- // ============================================================================
1059
- // cleanupAllExpiredSessions() Method
1060
- // ============================================================================
1061
-
1062
- describe('cleanupAllExpiredSessions', () => {
1063
- it('should delete all expired sessions and return count', async () => {
1064
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 10 } as any);
1065
-
1066
- const result = await service.cleanupAllExpiredSessions();
1067
-
1068
- expect(result).toBe(10);
1069
- expect(mockChallengeSessionRepository.delete).toHaveBeenCalledWith((expect as any).objectContaining({}));
1070
- expect(mockLogger.log).toHaveBeenCalledWith(
1071
- (expect as any).stringContaining('Cleaned up 10 expired challenge sessions'),
1072
- );
1073
- });
1074
-
1075
- it('should return 0 when no sessions deleted', async () => {
1076
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
1077
-
1078
- const result = await service.cleanupAllExpiredSessions();
1079
-
1080
- expect(result).toBe(0);
1081
- expect(mockLogger.log).toHaveBeenCalledWith(
1082
- (expect as any).stringContaining('Cleaned up 0 expired challenge sessions'),
1083
- );
1084
- });
1085
-
1086
- it('should handle delete result without affected property', async () => {
1087
- mockChallengeSessionRepository.delete.mockResolvedValue({} as any);
1088
-
1089
- const result = await service.cleanupAllExpiredSessions();
1090
-
1091
- expect(result).toBe(0);
1092
- });
1093
- });
1094
-
1095
- // ============================================================================
1096
- // deleteUserChallengeSessions() Method
1097
- // ============================================================================
1098
-
1099
- describe('deleteUserChallengeSessions', () => {
1100
- it('should delete active challenge sessions by type', async () => {
1101
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 2 } as any);
1102
-
1103
- const result = await service.deleteUserChallengeSessions(1, AuthChallenge.MFA_SETUP_REQUIRED);
1104
-
1105
- expect(result).toBe(2);
1106
- expect(mockChallengeSessionRepository.delete).toHaveBeenCalledWith({
1107
- userId: 1,
1108
- challengeName: AuthChallenge.MFA_SETUP_REQUIRED,
1109
- isCompleted: false,
1110
- });
1111
- expect(mockLogger.log).toHaveBeenCalledWith(
1112
- (expect as any).stringContaining('Deleted 2 MFA_SETUP_REQUIRED challenge session(s)'),
1113
- );
1114
- });
1115
-
1116
- it('should return 0 when no sessions deleted', async () => {
1117
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
1118
-
1119
- const result = await service.deleteUserChallengeSessions(1, AuthChallenge.VERIFY_EMAIL);
1120
-
1121
- expect(result).toBe(0);
1122
- expect(mockLogger.log).not.toHaveBeenCalled();
1123
- });
1124
-
1125
- it('should delete sessions for all challenge types', async () => {
1126
- const challengeTypes = [
1127
- AuthChallenge.VERIFY_EMAIL,
1128
- AuthChallenge.VERIFY_PHONE,
1129
- AuthChallenge.MFA_REQUIRED,
1130
- AuthChallenge.MFA_SETUP_REQUIRED,
1131
- AuthChallenge.FORCE_CHANGE_PASSWORD,
1132
- ];
1133
-
1134
- for (const challengeType of challengeTypes) {
1135
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 1 } as any);
1136
- const result = await service.deleteUserChallengeSessions(1, challengeType);
1137
- expect(result).toBe(1);
1138
- expect(mockChallengeSessionRepository.delete).toHaveBeenCalledWith(
1139
- (expect as any).objectContaining({
1140
- challengeName: challengeType,
1141
- }),
1142
- );
1143
- }
1144
- });
1145
- });
1146
-
1147
- // ============================================================================
1148
- // Helper Methods
1149
- // ============================================================================
1150
-
1151
- describe('maskEmail', () => {
1152
- it('should mask email address correctly', () => {
1153
- const masked = service.maskEmail('john.doe@example.com');
1154
- expect(masked).toBe('j***@example.com');
1155
- });
1156
-
1157
- it('should handle single character local part', () => {
1158
- const masked = service.maskEmail('j@example.com');
1159
- expect(masked).toBe('j***@example.com');
1160
- });
1161
-
1162
- it('should handle invalid email gracefully', () => {
1163
- const masked = service.maskEmail('invalid-email');
1164
- expect(masked).toBe('invalid-email');
1165
- });
1166
-
1167
- it('should handle email without @ symbol', () => {
1168
- const masked = service.maskEmail('noatdomain');
1169
- expect(masked).toBe('noatdomain');
1170
- });
1171
-
1172
- it('should handle empty email', () => {
1173
- const masked = service.maskEmail('');
1174
- expect(masked).toBe('');
1175
- });
1176
-
1177
- it('should mask long email addresses', () => {
1178
- const masked = service.maskEmail('verylongemailaddress@example.com');
1179
- expect(masked).toBe('v***@example.com');
1180
- });
1181
-
1182
- it('should handle email with empty local part', () => {
1183
- const masked = service.maskEmail('@example.com');
1184
- // When split('@'), first part is empty string, so localPart[0] is undefined
1185
- // Implementation concatenates undefined with '***@' resulting in 'undefined***@example.com'
1186
- expect(masked).toBe('undefined***@example.com');
1187
- });
1188
-
1189
- it('should handle email with multiple @ symbols', () => {
1190
- // split('@') on 'invalid@email@example.com' creates ['invalid', 'email', 'example.com']
1191
- // Takes first element as localPart and second as domain
1192
- const masked = service.maskEmail('invalid@email@example.com');
1193
- expect(masked).toBe('i***@email');
1194
- });
1195
-
1196
- it('should handle email with special characters in local part', () => {
1197
- const masked = service.maskEmail('user+tag@example.com');
1198
- expect(masked).toBe('u***@example.com');
1199
- });
1200
- });
1201
-
1202
- describe('maskPhone', () => {
1203
- it('should mask phone number correctly', () => {
1204
- const masked = service.maskPhone('+1234567890');
1205
- expect(masked).toBe('***-***-7890');
1206
- });
1207
-
1208
- it('should handle phone with formatting', () => {
1209
- const masked = service.maskPhone('+1 (234) 567-8901');
1210
- expect(masked).toBe('***-***-8901');
1211
- });
1212
-
1213
- it('should handle short phone numbers', () => {
1214
- const masked = service.maskPhone('123');
1215
- expect(masked).toBe('123');
1216
- });
1217
-
1218
- it('should handle phone with exactly 4 digits', () => {
1219
- const masked = service.maskPhone('1234');
1220
- expect(masked).toBe('***-***-1234');
1221
- });
1222
-
1223
- it('should handle phone with less than 4 digits', () => {
1224
- const masked = service.maskPhone('12');
1225
- expect(masked).toBe('12');
1226
- });
1227
-
1228
- it('should handle phone with only special characters', () => {
1229
- const masked = service.maskPhone('+--()');
1230
- expect(masked).toBe('+--()');
1231
- });
1232
-
1233
- it('should handle international phone numbers', () => {
1234
- const masked = service.maskPhone('+441234567890');
1235
- expect(masked).toBe('***-***-7890');
1236
- });
1237
-
1238
- it('should handle empty phone', () => {
1239
- const masked = service.maskPhone('');
1240
- expect(masked).toBe('');
1241
- });
1242
-
1243
- it('should handle phone with only letters', () => {
1244
- const masked = service.maskPhone('abc');
1245
- expect(masked).toBe('abc');
1246
- });
1247
-
1248
- it('should handle phone with mixed characters', () => {
1249
- // maskPhone extracts digits only: '+1-800-CALL-NOW' -> '1800' -> last 4 = '1800'
1250
- const masked = service.maskPhone('+1-800-CALL-NOW');
1251
- expect(masked).toBe('***-***-1800');
1252
- });
1253
-
1254
- it('should handle phone with exactly 3 digits', () => {
1255
- const masked = service.maskPhone('123');
1256
- expect(masked).toBe('123');
1257
- });
1258
- });
1259
-
1260
- // ============================================================================
1261
- // Edge Cases and Error Handling
1262
- // ============================================================================
1263
-
1264
- describe('Edge Cases', () => {
1265
- it('should handle service without audit service', async () => {
1266
- const serviceWithoutAudit = new ChallengeService(
1267
- mockChallengeSessionRepository,
1268
- mockClientInfoService,
1269
- mockLogger,
1270
- undefined, // No audit service
1271
- );
1272
-
1273
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
1274
- mockChallengeSessionRepository.create.mockReturnValue(mockChallengeSession as any);
1275
- mockChallengeSessionRepository.save.mockResolvedValue(mockChallengeSession as any);
1276
-
1277
- const result = await serviceWithoutAudit.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL);
1278
-
1279
- expect(result).toBeDefined();
1280
- // Should not throw
1281
- });
1282
-
1283
- it('should handle repository save errors', async () => {
1284
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
1285
- mockChallengeSessionRepository.create.mockReturnValue(mockChallengeSession as any);
1286
- mockChallengeSessionRepository.save.mockRejectedValue(new Error('Database error'));
1287
-
1288
- try {
1289
- await service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL);
1290
- fail('Should have thrown error');
1291
- } catch (error) {
1292
- expect((error as Error).message).toContain('Database error');
1293
- }
1294
- });
1295
-
1296
- it('should handle concurrent session creation', async () => {
1297
- mockChallengeSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
1298
- mockChallengeSessionRepository.create
1299
- .mockReturnValueOnce({ ...mockChallengeSession, challengeName: AuthChallenge.VERIFY_EMAIL } as any)
1300
- .mockReturnValueOnce({ ...mockChallengeSession, challengeName: AuthChallenge.VERIFY_PHONE } as any);
1301
- mockChallengeSessionRepository.save
1302
- .mockResolvedValueOnce({ ...mockChallengeSession, challengeName: AuthChallenge.VERIFY_EMAIL } as any)
1303
- .mockResolvedValueOnce({ ...mockChallengeSession, challengeName: AuthChallenge.VERIFY_PHONE } as any);
1304
- mockAuditService.recordEvent.mockResolvedValue({} as any);
1305
-
1306
- const promises = [
1307
- service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_EMAIL),
1308
- service.createChallengeSession(mockUser as IUser, AuthChallenge.VERIFY_PHONE),
1309
- ];
1310
-
1311
- const results = await Promise.all(promises);
1312
-
1313
- expect(results.length).toBe(2);
1314
- expect(results[0].challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
1315
- expect(results[1].challengeName).toBe(AuthChallenge.VERIFY_PHONE);
1316
- });
1317
-
1318
- it('should handle validateAndConsumeSession for all challenge types', async () => {
1319
- const challengeTypes = [
1320
- AuthChallenge.VERIFY_EMAIL,
1321
- AuthChallenge.VERIFY_PHONE,
1322
- AuthChallenge.MFA_REQUIRED,
1323
- AuthChallenge.MFA_SETUP_REQUIRED,
1324
- AuthChallenge.FORCE_CHANGE_PASSWORD,
1325
- ];
1326
-
1327
- for (const challengeType of challengeTypes) {
1328
- const validSession = {
1329
- ...mockChallengeSession,
1330
- challengeName: challengeType,
1331
- expiresAt: new Date(Date.now() + 60000),
1332
- };
1333
- mockChallengeSessionRepository.findOne.mockResolvedValue(validSession as any);
1334
- const completedSession = { ...validSession, isCompleted: true };
1335
- mockChallengeSessionRepository.save.mockResolvedValue(completedSession as any);
1336
- mockAuditService.recordEvent.mockResolvedValue({} as any);
1337
-
1338
- const result = await service.validateAndConsumeSession('session-token-123', challengeType);
1339
-
1340
- expect(result.isCompleted).toBe(true);
1341
- expect(result.challengeName).toBe(challengeType);
1342
- jest.clearAllMocks();
1343
- }
1344
- });
1345
-
1346
- it('should handle session validation with null user gracefully', async () => {
1347
- const sessionWithoutUser = {
1348
- ...mockChallengeSession,
1349
- expiresAt: new Date(Date.now() + 60000),
1350
- user: null,
1351
- };
1352
- mockChallengeSessionRepository.findOne.mockResolvedValue(sessionWithoutUser as any);
1353
-
1354
- try {
1355
- await service.validateSession('session-token-123');
1356
- // Should not throw if user is null but session is valid
1357
- } catch (error) {
1358
- // Only expiresAt check might fail if user is needed for logging
1359
- // But validation should still work
1360
- }
1361
- });
1362
- });
1363
- });