@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,1058 +0,0 @@
1
- import { AdaptiveMFADecisionService } from './adaptive-mfa-decision.service';
2
- import { RiskDetectionService } from './risk-detection.service';
3
- import { RiskScoringService } from './risk-scoring.service';
4
- import { StorageAdapter } from '../interfaces/storage-adapter.interface';
5
- import { AuthAuditService } from './auth-audit.service';
6
- import { ClientInfoService } from './client-info.service';
7
- import { IUser } from '../interfaces/entities.interface';
8
- import { NAuthConfig, AdaptiveMFARiskEventPayload } from '../interfaces/config.interface';
9
- import { NAuthLogger } from '../utils/nauth-logger';
10
- import { ClientInfo } from '../interfaces/client-info.interface';
11
- import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
12
- import { RiskFactor } from '../enums/risk-factor.enum';
13
-
14
- /**
15
- * Adaptive MFA Decision Service Unit Tests
16
- *
17
- * Platform-agnostic: Uses direct instantiation, no NestJS dependencies.
18
- *
19
- * Covers:
20
- * - Risk evaluation and decision making
21
- * - Risk level determination (low, medium, high)
22
- * - Action determination (allow, require_mfa, block_signin)
23
- * - Lifecycle hook integration (onAdaptiveMFATriggered, onSignInBlocked)
24
- * - User blocking functionality (isUserBlocked, blockUserSignIn)
25
- * - Configuration-based risk level customization
26
- * - Error handling and graceful degradation
27
- */
28
- describe('AdaptiveMFADecisionService', () => {
29
- let service: AdaptiveMFADecisionService;
30
- let mockRiskDetectionService: jest.Mocked<RiskDetectionService>;
31
- let mockRiskScoringService: jest.Mocked<RiskScoringService>;
32
- let mockStorageAdapter: jest.Mocked<StorageAdapter>;
33
- let mockAuditService: jest.Mocked<AuthAuditService>;
34
- let mockClientInfoService: jest.Mocked<ClientInfoService>;
35
- let mockConfig: NAuthConfig;
36
- let mockLogger: jest.Mocked<NAuthLogger>;
37
-
38
- const mockUser: IUser = {
39
- id: 1,
40
- sub: 'user-123',
41
- email: 'test@example.com',
42
- username: 'testuser',
43
- phone: null,
44
- firstName: null,
45
- lastName: null,
46
- passwordHash: null,
47
- passwordChangedAt: null,
48
- passwordHistory: null,
49
- isEmailVerified: true,
50
- isPhoneVerified: false,
51
- isActive: true,
52
- mustChangePassword: false,
53
- isLocked: false,
54
- lockReason: null,
55
- lockedAt: null,
56
- lockedUntil: null,
57
- failedLoginAttempts: 0,
58
- lastFailedLoginAt: null,
59
- lastLoginAt: null,
60
- lastLoginIp: null,
61
- hasSocialAuth: false,
62
- socialProviders: null,
63
- mfaEnabled: false,
64
- mfaMethods: null,
65
- preferredMfaMethod: null,
66
- backupCodes: null,
67
- metadata: null,
68
- createdAt: new Date(),
69
- updatedAt: new Date(),
70
- deletedAt: null,
71
- };
72
-
73
- const mockClientInfo: ClientInfo = {
74
- ipAddress: '192.168.1.100',
75
- userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
76
- deviceToken: 'device-123',
77
- deviceName: 'Chrome on Windows',
78
- deviceType: 'desktop',
79
- ipCountry: 'US',
80
- ipCity: 'New York',
81
- platform: 'Windows',
82
- browser: 'Chrome',
83
- };
84
-
85
- beforeEach(() => {
86
- mockRiskDetectionService = {
87
- detectRiskFactors: jest.fn(),
88
- } as any;
89
-
90
- mockRiskScoringService = {
91
- calculateRiskScore: jest.fn(),
92
- getRiskLevel: jest.fn(),
93
- } as any;
94
-
95
- mockStorageAdapter = {
96
- get: jest.fn(),
97
- set: jest.fn(),
98
- del: jest.fn(),
99
- exists: jest.fn(),
100
- incr: jest.fn(),
101
- decr: jest.fn(),
102
- expire: jest.fn(),
103
- ttl: jest.fn(),
104
- hget: jest.fn(),
105
- hset: jest.fn(),
106
- hgetall: jest.fn(),
107
- hdel: jest.fn(),
108
- lpush: jest.fn(),
109
- lrange: jest.fn(),
110
- llen: jest.fn(),
111
- keys: jest.fn(),
112
- scan: jest.fn(),
113
- initialize: jest.fn(),
114
- isHealthy: jest.fn(),
115
- cleanup: jest.fn(),
116
- disconnect: jest.fn(),
117
- } as any;
118
-
119
- mockAuditService = {
120
- recordEvent: jest.fn().mockResolvedValue(null),
121
- } as any;
122
-
123
- mockClientInfoService = {
124
- get: jest.fn().mockReturnValue(mockClientInfo),
125
- } as any;
126
-
127
- mockLogger = {
128
- log: jest.fn(),
129
- error: jest.fn(),
130
- warn: jest.fn(),
131
- debug: jest.fn(),
132
- verbose: jest.fn(),
133
- } as any;
134
-
135
- mockConfig = {
136
- jwt: {
137
- accessToken: { secret: 'test-secret', expiresIn: '15m' },
138
- refreshToken: { secret: 'test-refresh-secret', expiresIn: '7d' },
139
- },
140
- mfa: {
141
- adaptive: {
142
- triggers: ['new_device', 'new_ip', 'new_country'],
143
- riskLevels: {
144
- low: { maxScore: 20, action: 'allow', notifyUser: false },
145
- medium: { maxScore: 50, action: 'require_mfa', notifyUser: true },
146
- high: { maxScore: 100, action: 'require_mfa', notifyUser: true },
147
- },
148
- },
149
- },
150
- };
151
-
152
- // Instantiate service directly
153
- service = new AdaptiveMFADecisionService(
154
- mockRiskDetectionService,
155
- mockRiskScoringService,
156
- mockStorageAdapter,
157
- mockClientInfoService,
158
- mockConfig,
159
- mockLogger,
160
- mockAuditService,
161
- );
162
- });
163
-
164
- afterEach(() => {
165
- jest.clearAllMocks();
166
- });
167
-
168
- // ============================================================================
169
- // Service Initialization
170
- // ============================================================================
171
-
172
- it('should be defined', () => {
173
- expect(service).toBeDefined();
174
- // Verify clientInfoService is injected
175
- expect((service as any).clientInfoService).toBe(mockClientInfoService);
176
- });
177
-
178
- // ============================================================================
179
- // evaluateAdaptiveMFA - Low Risk
180
- // ============================================================================
181
-
182
- describe('evaluateAdaptiveMFA() - low risk', () => {
183
- it('should return allow action for low risk score', async () => {
184
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
185
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_DEVICE]);
186
- mockRiskScoringService.calculateRiskScore.mockReturnValue(15); // Low risk
187
- mockRiskScoringService.getRiskLevel.mockReturnValue('low');
188
- mockAuditService.recordEvent.mockResolvedValue(null);
189
-
190
- const decision = await service.evaluateAdaptiveMFA(mockUser, 'password');
191
-
192
- expect(decision.action).toBe('allow');
193
- expect(decision.riskScore).toBe(15);
194
- expect(decision.riskLevel).toBe('low');
195
- expect(decision.riskFactors).toEqual([RiskFactor.NEW_DEVICE]);
196
- // Payload should not be included for low risk (allow action, notifyUser false)
197
- expect(decision.payload).toBeUndefined();
198
- expect(decision.notifyUser).toBe(false);
199
- expect(decision.hookOverride).toBe(false);
200
- });
201
-
202
- it('should not call lifecycle hook for low risk when notifyUser is false', async () => {
203
- const mockHook = jest.fn();
204
- const testConfig: NAuthConfig = {
205
- ...mockConfig,
206
- hooks: {
207
- onAdaptiveMFATriggered: mockHook,
208
- },
209
- };
210
-
211
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
212
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([]);
213
- mockRiskScoringService.calculateRiskScore.mockReturnValue(0);
214
- mockRiskScoringService.getRiskLevel.mockReturnValue('low');
215
-
216
- const testService = new AdaptiveMFADecisionService(
217
- mockRiskDetectionService,
218
- mockRiskScoringService,
219
- mockStorageAdapter,
220
- mockClientInfoService,
221
- testConfig,
222
- mockLogger,
223
- mockAuditService,
224
- );
225
-
226
- await testService.evaluateAdaptiveMFA(mockUser, 'password');
227
-
228
- expect(mockHook).not.toHaveBeenCalled();
229
- });
230
- });
231
-
232
- // ============================================================================
233
- // evaluateAdaptiveMFA - Medium Risk
234
- // ============================================================================
235
-
236
- describe('evaluateAdaptiveMFA() - medium risk', () => {
237
- it('should return require_mfa action for medium risk score', async () => {
238
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
239
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_COUNTRY]);
240
- mockRiskScoringService.calculateRiskScore.mockReturnValue(35); // Medium risk
241
- mockRiskScoringService.getRiskLevel.mockReturnValue('medium');
242
- mockAuditService.recordEvent.mockResolvedValue(null);
243
-
244
- const decision = await service.evaluateAdaptiveMFA(mockUser, 'password');
245
-
246
- expect(decision.action).toBe('require_mfa');
247
- expect(decision.riskScore).toBe(35);
248
- expect(decision.riskLevel).toBe('medium');
249
- expect(decision.notifyUser).toBe(true);
250
- // Payload should be included when notifyUser is true
251
- expect(decision.payload).toBeDefined();
252
- expect(decision.payload?.action).toBe('require_mfa');
253
- expect(decision.payload?.user.email).toBe('test@example.com');
254
- });
255
-
256
- it('should call lifecycle hook for medium risk when notifyUser is true', async () => {
257
- const mockHook = jest.fn().mockResolvedValue(undefined);
258
- const testConfig: NAuthConfig = {
259
- ...mockConfig,
260
- hooks: {
261
- onAdaptiveMFATriggered: mockHook,
262
- },
263
- };
264
-
265
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
266
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_COUNTRY]);
267
- mockRiskScoringService.calculateRiskScore.mockReturnValue(35);
268
- mockRiskScoringService.getRiskLevel.mockReturnValue('medium');
269
-
270
- const testService = new AdaptiveMFADecisionService(
271
- mockRiskDetectionService,
272
- mockRiskScoringService,
273
- mockStorageAdapter,
274
- mockClientInfoService,
275
- testConfig,
276
- mockLogger,
277
- mockAuditService,
278
- );
279
-
280
- await testService.evaluateAdaptiveMFA(mockUser, 'password');
281
-
282
- expect(mockHook).toHaveBeenCalledTimes(1);
283
- const payload: AdaptiveMFARiskEventPayload = mockHook.mock.calls[0][0];
284
- expect(payload.user.sub).toBe('user-123');
285
- expect(payload.user.email).toBe('test@example.com');
286
- expect(payload.riskScore).toBe(35);
287
- expect(payload.riskLevel).toBe('medium');
288
- expect(payload.riskFactors).toEqual(['new_country']);
289
- expect(payload.action).toBe('require_mfa');
290
- });
291
-
292
- it('should allow hook to override action by returning false', async () => {
293
- const mockHook = jest.fn().mockResolvedValue(false); // Override
294
- const testConfig: NAuthConfig = {
295
- ...mockConfig,
296
- hooks: {
297
- onAdaptiveMFATriggered: mockHook,
298
- },
299
- };
300
-
301
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
302
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_COUNTRY]);
303
- mockRiskScoringService.calculateRiskScore.mockReturnValue(35);
304
- mockRiskScoringService.getRiskLevel.mockReturnValue('medium');
305
-
306
- const testService = new AdaptiveMFADecisionService(
307
- mockRiskDetectionService,
308
- mockRiskScoringService,
309
- mockStorageAdapter,
310
- mockClientInfoService,
311
- testConfig,
312
- mockLogger,
313
- mockAuditService,
314
- );
315
-
316
- const decision = await testService.evaluateAdaptiveMFA(mockUser, 'password');
317
-
318
- expect(decision.action).toBe('allow'); // Overridden
319
- expect(decision.hookOverride).toBe(true);
320
- expect(mockHook).toHaveBeenCalled();
321
- });
322
- });
323
-
324
- // ============================================================================
325
- // evaluateAdaptiveMFA - High Risk
326
- // ============================================================================
327
-
328
- describe('evaluateAdaptiveMFA() - high risk', () => {
329
- it('should return require_mfa action for high risk by default', async () => {
330
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
331
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([
332
- RiskFactor.IMPOSSIBLE_TRAVEL,
333
- RiskFactor.SUSPICIOUS_ACTIVITY,
334
- ]);
335
- mockRiskScoringService.calculateRiskScore.mockReturnValue(70); // High risk
336
- mockRiskScoringService.getRiskLevel.mockReturnValue('high');
337
- mockAuditService.recordEvent.mockResolvedValue(null);
338
-
339
- const decision = await service.evaluateAdaptiveMFA(mockUser, 'password');
340
-
341
- expect(decision.action).toBe('require_mfa');
342
- expect(decision.riskScore).toBe(70);
343
- expect(decision.riskLevel).toBe('high');
344
- expect(decision.notifyUser).toBe(true);
345
- });
346
-
347
- it('should return block_signin action when configured for high risk', async () => {
348
- const testConfig: NAuthConfig = {
349
- ...mockConfig,
350
- mfa: {
351
- ...mockConfig.mfa,
352
- adaptive: {
353
- ...mockConfig.mfa!.adaptive!,
354
- riskLevels: {
355
- ...mockConfig.mfa!.adaptive!.riskLevels,
356
- high: {
357
- maxScore: 100,
358
- action: 'block_signin' as const,
359
- notifyUser: true,
360
- },
361
- },
362
- },
363
- },
364
- };
365
-
366
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
367
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([
368
- RiskFactor.IMPOSSIBLE_TRAVEL,
369
- RiskFactor.SUSPICIOUS_ACTIVITY,
370
- ]);
371
- mockRiskScoringService.calculateRiskScore.mockReturnValue(70);
372
- mockRiskScoringService.getRiskLevel.mockReturnValue('high');
373
-
374
- const testService = new AdaptiveMFADecisionService(
375
- mockRiskDetectionService,
376
- mockRiskScoringService,
377
- mockStorageAdapter,
378
- mockClientInfoService,
379
- testConfig,
380
- mockLogger,
381
- mockAuditService,
382
- );
383
-
384
- const decision = await testService.evaluateAdaptiveMFA(mockUser, 'password');
385
-
386
- expect(decision.action).toBe('block_signin');
387
- expect(decision.riskScore).toBe(70);
388
- expect(decision.riskLevel).toBe('high');
389
- // Payload should be included for block_signin action
390
- expect(decision.payload).toBeDefined();
391
- expect(decision.payload?.action).toBe('block_signin');
392
- expect(decision.payload?.riskScore).toBe(70);
393
- expect(decision.payload?.user.email).toBe('test@example.com');
394
- });
395
-
396
- it('should return block_signin action and include payload for blockUserSignIn', async () => {
397
- const testConfig: NAuthConfig = {
398
- ...mockConfig,
399
- mfa: {
400
- ...mockConfig.mfa,
401
- adaptive: {
402
- ...mockConfig.mfa!.adaptive!,
403
- riskLevels: {
404
- ...mockConfig.mfa!.adaptive!.riskLevels,
405
- high: {
406
- maxScore: 100,
407
- action: 'block_signin' as const,
408
- notifyUser: true,
409
- },
410
- },
411
- },
412
- },
413
- };
414
-
415
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
416
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.IMPOSSIBLE_TRAVEL]);
417
- mockRiskScoringService.calculateRiskScore.mockReturnValue(70);
418
- mockRiskScoringService.getRiskLevel.mockReturnValue('high');
419
-
420
- const testService = new AdaptiveMFADecisionService(
421
- mockRiskDetectionService,
422
- mockRiskScoringService,
423
- mockStorageAdapter,
424
- mockClientInfoService,
425
- testConfig,
426
- mockLogger,
427
- mockAuditService,
428
- );
429
-
430
- const decision = await testService.evaluateAdaptiveMFA(mockUser, 'password');
431
-
432
- expect(decision.action).toBe('block_signin');
433
- // Payload should be included for block_signin action (caller can use it to call blockUserSignIn)
434
- expect(decision.payload).toBeDefined();
435
- expect(decision.payload?.action).toBe('block_signin');
436
- });
437
- });
438
-
439
- // ============================================================================
440
- // evaluateAdaptiveMFA - Configuration
441
- // ============================================================================
442
-
443
- describe('evaluateAdaptiveMFA() - configuration', () => {
444
- it('should use default risk levels when not configured', async () => {
445
- const testConfig: NAuthConfig = {
446
- ...mockConfig,
447
- mfa: {
448
- ...mockConfig.mfa,
449
- adaptive: {
450
- ...mockConfig.mfa!.adaptive!,
451
- riskLevels: undefined,
452
- },
453
- },
454
- };
455
-
456
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
457
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_DEVICE]);
458
- mockRiskScoringService.calculateRiskScore.mockReturnValue(15);
459
- mockRiskScoringService.getRiskLevel.mockReturnValue('low');
460
-
461
- const testService = new AdaptiveMFADecisionService(
462
- mockRiskDetectionService,
463
- mockRiskScoringService,
464
- mockStorageAdapter,
465
- mockClientInfoService,
466
- testConfig,
467
- mockLogger,
468
- mockAuditService,
469
- );
470
-
471
- const decision = await testService.evaluateAdaptiveMFA(mockUser, 'password');
472
-
473
- expect(decision.action).toBe('allow'); // Default for low
474
- });
475
-
476
- it('should respect custom risk level thresholds', async () => {
477
- const testConfig: NAuthConfig = {
478
- ...mockConfig,
479
- mfa: {
480
- ...mockConfig.mfa,
481
- adaptive: {
482
- ...mockConfig.mfa!.adaptive!,
483
- riskLevels: {
484
- low: { maxScore: 30, action: 'allow' as const, notifyUser: false },
485
- medium: { maxScore: 70, action: 'require_mfa' as const, notifyUser: true },
486
- high: { maxScore: 100, action: 'require_mfa' as const, notifyUser: true },
487
- },
488
- },
489
- },
490
- };
491
-
492
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
493
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_DEVICE]);
494
- mockRiskScoringService.calculateRiskScore.mockReturnValue(25); // Now in low range
495
- mockRiskScoringService.getRiskLevel.mockReturnValue('low');
496
-
497
- const testService = new AdaptiveMFADecisionService(
498
- mockRiskDetectionService,
499
- mockRiskScoringService,
500
- mockStorageAdapter,
501
- mockClientInfoService,
502
- testConfig,
503
- mockLogger,
504
- mockAuditService,
505
- );
506
-
507
- const decision = await testService.evaluateAdaptiveMFA(mockUser, 'password');
508
-
509
- expect(decision.action).toBe('allow');
510
- });
511
- });
512
-
513
- // ============================================================================
514
- // evaluateAdaptiveMFA - Audit Logging
515
- // ============================================================================
516
-
517
- describe('evaluateAdaptiveMFA() - audit logging', () => {
518
- it('should record audit event with risk details', async () => {
519
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
520
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_COUNTRY]);
521
- mockRiskScoringService.calculateRiskScore.mockReturnValue(35);
522
- mockRiskScoringService.getRiskLevel.mockReturnValue('medium');
523
- mockAuditService.recordEvent.mockResolvedValue(null);
524
-
525
- await service.evaluateAdaptiveMFA(mockUser, 'password');
526
-
527
- expect(mockAuditService.recordEvent).toHaveBeenCalled();
528
- const auditCall = mockAuditService.recordEvent.mock.calls[0][0];
529
- expect(auditCall.userId).toBe(1);
530
- expect(auditCall.eventType).toBe(AuthAuditEventType.ADAPTIVE_MFA_RISK_ASSESSED);
531
- expect(auditCall.riskFactors).toEqual([RiskFactor.NEW_COUNTRY]);
532
- expect(auditCall.riskFactor).toBe(35);
533
- expect(auditCall.adaptiveMfaTriggered).toBe(true);
534
- });
535
-
536
- it('should handle audit logging errors gracefully', async () => {
537
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
538
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_DEVICE]);
539
- mockRiskScoringService.calculateRiskScore.mockReturnValue(15);
540
- mockRiskScoringService.getRiskLevel.mockReturnValue('low');
541
- // recordEvent returns a promise that rejects - service catches it
542
- mockAuditService.recordEvent.mockImplementation(() => Promise.reject(new Error('Audit error')));
543
-
544
- // Should not throw
545
- const decision = await service.evaluateAdaptiveMFA(mockUser, 'password');
546
-
547
- expect(decision).toBeDefined();
548
- // Service uses .catch() so error is handled internally
549
- });
550
- });
551
-
552
- // ============================================================================
553
- // isUserBlocked
554
- // ============================================================================
555
-
556
- describe('isUserBlocked()', () => {
557
- it('should return blocked=false when no block exists', async () => {
558
- mockStorageAdapter.get.mockClear();
559
- mockStorageAdapter.get.mockResolvedValue(null);
560
-
561
- const result = await service.isUserBlocked(1);
562
-
563
- expect(result.blocked).toBe(false);
564
- expect(mockStorageAdapter.get).toHaveBeenCalledWith('adaptive_mfa_block:1');
565
- });
566
-
567
- it('should return blocked=true when block exists', async () => {
568
- const blockData = {
569
- userId: 1,
570
- userSub: 'user-123',
571
- message: 'Sign-in blocked',
572
- riskScore: 70,
573
- riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
574
- blockedAt: new Date().toISOString(),
575
- expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour from now
576
- };
577
-
578
- mockStorageAdapter.get.mockClear();
579
- mockStorageAdapter.get.mockResolvedValue(JSON.stringify(blockData));
580
-
581
- const result = await service.isUserBlocked(1);
582
-
583
- expect(result.blocked).toBe(true);
584
- expect(result.message).toBe('Sign-in blocked');
585
- expect(result.expiresAt).toBeInstanceOf(Date);
586
- });
587
-
588
- it('should return blocked=false when block has expired', async () => {
589
- const expiredBlockData = {
590
- userId: 1,
591
- userSub: 'user-123',
592
- message: 'Sign-in blocked',
593
- riskScore: 70,
594
- riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
595
- blockedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago
596
- expiresAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), // 1 hour ago (expired)
597
- };
598
-
599
- mockStorageAdapter.get.mockClear();
600
- mockStorageAdapter.get.mockResolvedValue(JSON.stringify(expiredBlockData));
601
- mockStorageAdapter.del.mockClear();
602
- mockStorageAdapter.del.mockResolvedValue(undefined);
603
-
604
- const result = await service.isUserBlocked(1);
605
-
606
- expect(result.blocked).toBe(false);
607
- expect(mockStorageAdapter.del).toHaveBeenCalledWith('adaptive_mfa_block:1');
608
- });
609
-
610
- it('should handle permanent blocks (no expiration)', async () => {
611
- const permanentBlockData: any = {
612
- userId: 1,
613
- userSub: 'user-123',
614
- message: 'Sign-in blocked',
615
- riskScore: 70,
616
- riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
617
- blockedAt: new Date().toISOString(),
618
- };
619
- // expiresAt is omitted (not set) for permanent blocks
620
-
621
- mockStorageAdapter.get.mockClear();
622
- mockStorageAdapter.get.mockResolvedValue(JSON.stringify(permanentBlockData));
623
-
624
- const result = await service.isUserBlocked(1);
625
-
626
- expect(result.blocked).toBe(true);
627
- expect(result.expiresAt).toBeUndefined();
628
- });
629
-
630
- it('should handle errors gracefully', async () => {
631
- mockStorageAdapter.get.mockRejectedValueOnce(new Error('Storage error'));
632
-
633
- const result = await service.isUserBlocked(1);
634
-
635
- expect(result.blocked).toBe(false); // Safer default
636
- expect(mockLogger.warn).toHaveBeenCalled();
637
- });
638
- });
639
-
640
- // ============================================================================
641
- // blockUserSignIn
642
- // ============================================================================
643
-
644
- describe('blockUserSignIn()', () => {
645
- it('should block user with temporary TTL when blockDuration configured', async () => {
646
- const testConfig: NAuthConfig = {
647
- ...mockConfig,
648
- mfa: {
649
- ...mockConfig.mfa,
650
- adaptive: {
651
- ...mockConfig.mfa!.adaptive!,
652
- blockedSignIn: {
653
- blockDuration: 60, // 60 minutes
654
- message: 'Custom block message',
655
- },
656
- },
657
- },
658
- };
659
-
660
- const payload: AdaptiveMFARiskEventPayload = {
661
- user: {
662
- sub: 'user-123',
663
- email: 'test@example.com',
664
- username: 'testuser',
665
- },
666
- riskScore: 70,
667
- riskLevel: 'high',
668
- riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
669
- action: 'block_signin',
670
- clientInfo: {
671
- ipAddress: mockClientInfo.ipAddress,
672
- ipCountry: mockClientInfo.ipCountry,
673
- ipCity: mockClientInfo.ipCity,
674
- deviceId: mockClientInfo.deviceToken,
675
- deviceName: mockClientInfo.deviceName,
676
- deviceType: mockClientInfo.deviceType,
677
- userAgent: mockClientInfo.userAgent,
678
- platform: mockClientInfo.platform,
679
- browser: mockClientInfo.browser,
680
- },
681
- authMethod: 'password',
682
- timestamp: new Date(),
683
- };
684
-
685
- const testService = new AdaptiveMFADecisionService(
686
- mockRiskDetectionService,
687
- mockRiskScoringService,
688
- mockStorageAdapter,
689
- mockClientInfoService,
690
- testConfig,
691
- mockLogger,
692
- mockAuditService,
693
- );
694
-
695
- await testService.blockUserSignIn(mockUser, payload);
696
-
697
- const setCall = mockStorageAdapter.set.mock.calls[0];
698
- expect(setCall[0]).toBe('adaptive_mfa_block:1');
699
- expect(setCall[1]).toContain('Custom block message');
700
- expect(setCall[2]).toBe(3600); // 60 minutes in seconds
701
- expect(mockLogger.warn).toHaveBeenCalled();
702
- });
703
-
704
- it('should block user permanently when blockDuration not configured', async () => {
705
- const testConfig: NAuthConfig = {
706
- ...mockConfig,
707
- mfa: {
708
- ...mockConfig.mfa,
709
- adaptive: {
710
- ...mockConfig.mfa!.adaptive!,
711
- blockedSignIn: {
712
- message: 'Sign-in blocked',
713
- },
714
- },
715
- },
716
- };
717
-
718
- const payload: AdaptiveMFARiskEventPayload = {
719
- user: {
720
- sub: 'user-123',
721
- email: 'test@example.com',
722
- username: 'testuser',
723
- },
724
- riskScore: 70,
725
- riskLevel: 'high',
726
- riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
727
- action: 'block_signin',
728
- clientInfo: {
729
- ipAddress: mockClientInfo.ipAddress,
730
- ipCountry: mockClientInfo.ipCountry,
731
- ipCity: mockClientInfo.ipCity,
732
- deviceId: mockClientInfo.deviceToken,
733
- deviceName: mockClientInfo.deviceName,
734
- deviceType: mockClientInfo.deviceType,
735
- userAgent: mockClientInfo.userAgent,
736
- platform: mockClientInfo.platform,
737
- browser: mockClientInfo.browser,
738
- },
739
- authMethod: 'password',
740
- timestamp: new Date(),
741
- };
742
-
743
- const testService = new AdaptiveMFADecisionService(
744
- mockRiskDetectionService,
745
- mockRiskScoringService,
746
- mockStorageAdapter,
747
- mockClientInfoService,
748
- testConfig,
749
- mockLogger,
750
- mockAuditService,
751
- );
752
-
753
- await testService.blockUserSignIn(mockUser, payload);
754
-
755
- const setCall = mockStorageAdapter.set.mock.calls[0];
756
- expect(setCall[0]).toBe('adaptive_mfa_block:1');
757
- expect(typeof setCall[1]).toBe('string');
758
- expect(setCall[2]).toBeUndefined(); // No TTL
759
- });
760
-
761
- it('should call onSignInBlocked lifecycle hook', async () => {
762
- const mockHook = jest.fn().mockResolvedValue(undefined);
763
- const testConfig: NAuthConfig = {
764
- ...mockConfig,
765
- hooks: {
766
- onSignInBlocked: mockHook,
767
- },
768
- mfa: {
769
- ...mockConfig.mfa,
770
- adaptive: {
771
- ...mockConfig.mfa!.adaptive!,
772
- blockedSignIn: {
773
- blockDuration: 30,
774
- message: 'Blocked',
775
- },
776
- },
777
- },
778
- };
779
-
780
- const payload: AdaptiveMFARiskEventPayload = {
781
- user: {
782
- sub: 'user-123',
783
- email: 'test@example.com',
784
- username: 'testuser',
785
- },
786
- riskScore: 70,
787
- riskLevel: 'high',
788
- riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
789
- action: 'block_signin',
790
- clientInfo: {
791
- ipAddress: mockClientInfo.ipAddress,
792
- ipCountry: mockClientInfo.ipCountry,
793
- ipCity: mockClientInfo.ipCity,
794
- deviceId: mockClientInfo.deviceToken,
795
- deviceName: mockClientInfo.deviceName,
796
- deviceType: mockClientInfo.deviceType,
797
- userAgent: mockClientInfo.userAgent,
798
- platform: mockClientInfo.platform,
799
- browser: mockClientInfo.browser,
800
- },
801
- authMethod: 'password',
802
- timestamp: new Date(),
803
- };
804
-
805
- const testService = new AdaptiveMFADecisionService(
806
- mockRiskDetectionService,
807
- mockRiskScoringService,
808
- mockStorageAdapter,
809
- mockClientInfoService,
810
- testConfig,
811
- mockLogger,
812
- mockAuditService,
813
- );
814
-
815
- await testService.blockUserSignIn(mockUser, payload);
816
-
817
- expect(mockHook).toHaveBeenCalledTimes(1);
818
- const hookPayload = mockHook.mock.calls[0][0];
819
- expect(hookPayload.user.sub).toBe('user-123');
820
- expect(hookPayload.riskScore).toBe(70);
821
- expect(hookPayload.blockDuration).toBe(30);
822
- expect(hookPayload.message).toBe('Blocked');
823
- expect(hookPayload.blockExpiresAt).toBeDefined();
824
- });
825
-
826
- it('should handle hook errors gracefully', async () => {
827
- const mockHook = jest.fn().mockRejectedValue(new Error('Hook error'));
828
- const testConfig: NAuthConfig = {
829
- ...mockConfig,
830
- hooks: {
831
- onSignInBlocked: mockHook,
832
- },
833
- mfa: {
834
- ...mockConfig.mfa,
835
- adaptive: {
836
- ...mockConfig.mfa!.adaptive!,
837
- blockedSignIn: {
838
- blockDuration: 30,
839
- message: 'Blocked',
840
- },
841
- },
842
- },
843
- };
844
-
845
- const payload: AdaptiveMFARiskEventPayload = {
846
- user: {
847
- sub: 'user-123',
848
- email: 'test@example.com',
849
- username: 'testuser',
850
- },
851
- riskScore: 70,
852
- riskLevel: 'high',
853
- riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
854
- action: 'block_signin',
855
- clientInfo: {
856
- ipAddress: mockClientInfo.ipAddress,
857
- ipCountry: mockClientInfo.ipCountry,
858
- ipCity: mockClientInfo.ipCity,
859
- deviceId: mockClientInfo.deviceToken,
860
- deviceName: mockClientInfo.deviceName,
861
- deviceType: mockClientInfo.deviceType,
862
- userAgent: mockClientInfo.userAgent,
863
- platform: mockClientInfo.platform,
864
- browser: mockClientInfo.browser,
865
- },
866
- authMethod: 'password',
867
- timestamp: new Date(),
868
- };
869
-
870
- const testService = new AdaptiveMFADecisionService(
871
- mockRiskDetectionService,
872
- mockRiskScoringService,
873
- mockStorageAdapter,
874
- mockClientInfoService,
875
- testConfig,
876
- mockLogger,
877
- mockAuditService,
878
- );
879
-
880
- // Should not throw
881
- await testService.blockUserSignIn(mockUser, payload);
882
-
883
- expect(mockLogger.error).toHaveBeenCalled();
884
- });
885
-
886
- it('should use default message when not configured', async () => {
887
- const testConfig: NAuthConfig = {
888
- ...mockConfig,
889
- mfa: {
890
- ...mockConfig.mfa,
891
- adaptive: {
892
- ...mockConfig.mfa!.adaptive!,
893
- blockedSignIn: {},
894
- },
895
- },
896
- };
897
-
898
- const payload: AdaptiveMFARiskEventPayload = {
899
- user: {
900
- sub: 'user-123',
901
- email: 'test@example.com',
902
- username: 'testuser',
903
- },
904
- riskScore: 70,
905
- riskLevel: 'high',
906
- riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
907
- action: 'block_signin',
908
- clientInfo: {
909
- ipAddress: mockClientInfo.ipAddress,
910
- ipCountry: mockClientInfo.ipCountry,
911
- ipCity: mockClientInfo.ipCity,
912
- deviceId: mockClientInfo.deviceToken,
913
- deviceName: mockClientInfo.deviceName,
914
- deviceType: mockClientInfo.deviceType,
915
- userAgent: mockClientInfo.userAgent,
916
- platform: mockClientInfo.platform,
917
- browser: mockClientInfo.browser,
918
- },
919
- authMethod: 'password',
920
- timestamp: new Date(),
921
- };
922
-
923
- const testService = new AdaptiveMFADecisionService(
924
- mockRiskDetectionService,
925
- mockRiskScoringService,
926
- mockStorageAdapter,
927
- mockClientInfoService,
928
- testConfig,
929
- mockLogger,
930
- mockAuditService,
931
- );
932
-
933
- await testService.blockUserSignIn(mockUser, payload);
934
-
935
- const setCall = mockStorageAdapter.set.mock.calls[0];
936
- const blockData = JSON.parse(setCall[1] as string);
937
- expect(blockData.message).toContain('suspicious activity');
938
- });
939
- });
940
-
941
- // ============================================================================
942
- // evaluateAdaptiveMFA - Error Handling
943
- // ============================================================================
944
-
945
- describe('evaluateAdaptiveMFA() - error handling', () => {
946
- it('should handle risk detection errors gracefully', async () => {
947
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
948
- // Risk detection returns empty array on error (handled internally)
949
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([]);
950
- mockRiskScoringService.calculateRiskScore.mockReturnValue(0);
951
- mockRiskScoringService.getRiskLevel.mockReturnValue('low');
952
- mockAuditService.recordEvent.mockResolvedValue(null);
953
-
954
- const decision = await service.evaluateAdaptiveMFA(mockUser, 'password');
955
-
956
- expect(decision).toBeDefined();
957
- expect(decision.action).toBe('allow');
958
- });
959
-
960
- it('should handle missing client info gracefully', async () => {
961
- mockClientInfoService.get.mockReturnValue({
962
- ipAddress: 'unknown',
963
- userAgent: 'unknown',
964
- } as ClientInfo);
965
-
966
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([]);
967
- mockRiskScoringService.calculateRiskScore.mockReturnValue(0);
968
- mockRiskScoringService.getRiskLevel.mockReturnValue('low');
969
-
970
- const decision = await service.evaluateAdaptiveMFA(mockUser, 'password');
971
-
972
- expect(decision.action).toBe('allow');
973
- });
974
-
975
- it('should throw error when user email is missing', async () => {
976
- const userWithoutEmail: IUser = {
977
- ...mockUser,
978
- email: '', // Empty email
979
- };
980
-
981
- try {
982
- await service.evaluateAdaptiveMFA(userWithoutEmail, 'password');
983
- fail('Expected evaluateAdaptiveMFA to throw error for missing email');
984
- } catch (error) {
985
- expect(error).toBeInstanceOf(Error);
986
- expect((error as Error).message).toBe('User email is required for adaptive MFA evaluation');
987
- }
988
- });
989
-
990
- it('should throw error when user email is null', async () => {
991
- const userWithoutEmail: IUser = {
992
- ...mockUser,
993
- email: null as unknown as string, // Force null for test
994
- };
995
-
996
- try {
997
- await service.evaluateAdaptiveMFA(userWithoutEmail, 'password');
998
- fail('Expected evaluateAdaptiveMFA to throw error for null email');
999
- } catch (error) {
1000
- expect(error).toBeInstanceOf(Error);
1001
- expect((error as Error).message).toBe('User email is required for adaptive MFA evaluation');
1002
- }
1003
- });
1004
- });
1005
-
1006
- // ============================================================================
1007
- // clearUserBlock
1008
- // ============================================================================
1009
-
1010
- describe('clearUserBlock()', () => {
1011
- it('should clear user block successfully', async () => {
1012
- mockStorageAdapter.del.mockResolvedValue(undefined);
1013
-
1014
- await service.clearUserBlock(1);
1015
-
1016
- expect(mockStorageAdapter.del).toHaveBeenCalledWith('adaptive_mfa_block:1');
1017
- expect(mockLogger.log).toHaveBeenCalledWith((expect as any).stringContaining('User block cleared'));
1018
- });
1019
-
1020
- it('should handle errors gracefully', async () => {
1021
- mockStorageAdapter.del.mockRejectedValue(new Error('Storage error'));
1022
-
1023
- await service.clearUserBlock(1);
1024
-
1025
- expect(mockLogger.warn).toHaveBeenCalled();
1026
- // Should not throw
1027
- });
1028
- });
1029
-
1030
- // ============================================================================
1031
- // Service Without Optional Dependencies
1032
- // ============================================================================
1033
-
1034
- describe('Service without optional dependencies', () => {
1035
- it('should work without audit service', async () => {
1036
- const serviceWithoutAudit = new AdaptiveMFADecisionService(
1037
- mockRiskDetectionService,
1038
- mockRiskScoringService,
1039
- mockStorageAdapter,
1040
- mockClientInfoService,
1041
- mockConfig,
1042
- mockLogger,
1043
- undefined, // No audit service
1044
- );
1045
-
1046
- mockClientInfoService.get.mockReturnValue(mockClientInfo);
1047
- mockRiskDetectionService.detectRiskFactors.mockResolvedValue([]);
1048
- mockRiskScoringService.calculateRiskScore.mockReturnValue(0);
1049
- mockRiskScoringService.getRiskLevel.mockReturnValue('low');
1050
-
1051
- const decision = await serviceWithoutAudit.evaluateAdaptiveMFA(mockUser, 'password');
1052
-
1053
- // Should not throw error
1054
- expect(decision).toBeDefined();
1055
- expect(decision.action).toBe('allow');
1056
- });
1057
- });
1058
- });