@nauth-toolkit/core 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/LICENSE +90 -0
  2. package/README.md +30 -0
  3. package/package.json +7 -2
  4. package/jest.config.js +0 -15
  5. package/jest.setup.ts +0 -6
  6. package/src/adapters/database-columns.ts +0 -165
  7. package/src/adapters/express.adapter.ts +0 -385
  8. package/src/adapters/fastify.adapter.ts +0 -416
  9. package/src/adapters/index.ts +0 -16
  10. package/src/adapters/storage.factory.ts +0 -143
  11. package/src/bootstrap.ts +0 -374
  12. package/src/dto/auth-challenge.dto.ts +0 -231
  13. package/src/dto/auth-response.dto.ts +0 -253
  14. package/src/dto/challenge-response.dto.ts +0 -234
  15. package/src/dto/change-password-request.dto.ts +0 -50
  16. package/src/dto/change-password-response.dto.ts +0 -29
  17. package/src/dto/change-password.dto.ts +0 -57
  18. package/src/dto/error-response.dto.ts +0 -136
  19. package/src/dto/get-available-methods.dto.ts +0 -55
  20. package/src/dto/get-challenge-data-response.dto.ts +0 -28
  21. package/src/dto/get-challenge-data.dto.ts +0 -69
  22. package/src/dto/get-client-info.dto.ts +0 -104
  23. package/src/dto/get-device-token-response.dto.ts +0 -25
  24. package/src/dto/get-events-by-type.dto.ts +0 -76
  25. package/src/dto/get-ip-address-response.dto.ts +0 -24
  26. package/src/dto/get-mfa-status.dto.ts +0 -94
  27. package/src/dto/get-risk-assessment-history.dto.ts +0 -39
  28. package/src/dto/get-session-id-response.dto.ts +0 -25
  29. package/src/dto/get-setup-data-response.dto.ts +0 -31
  30. package/src/dto/get-setup-data.dto.ts +0 -75
  31. package/src/dto/get-suspicious-activity.dto.ts +0 -42
  32. package/src/dto/get-user-agent-response.dto.ts +0 -23
  33. package/src/dto/get-user-auth-history.dto.ts +0 -95
  34. package/src/dto/get-user-by-email.dto.ts +0 -61
  35. package/src/dto/get-user-by-id.dto.ts +0 -46
  36. package/src/dto/get-user-devices.dto.ts +0 -53
  37. package/src/dto/get-user-response.dto.ts +0 -17
  38. package/src/dto/has-provider.dto.ts +0 -56
  39. package/src/dto/index.ts +0 -57
  40. package/src/dto/is-trusted-device-response.dto.ts +0 -34
  41. package/src/dto/list-providers-response.dto.ts +0 -23
  42. package/src/dto/login.dto.ts +0 -95
  43. package/src/dto/logout-all-response.dto.ts +0 -24
  44. package/src/dto/logout-all.dto.ts +0 -65
  45. package/src/dto/logout-response.dto.ts +0 -25
  46. package/src/dto/logout.dto.ts +0 -64
  47. package/src/dto/refresh-token.dto.ts +0 -36
  48. package/src/dto/remove-devices.dto.ts +0 -85
  49. package/src/dto/resend-code-response.dto.ts +0 -32
  50. package/src/dto/resend-code.dto.ts +0 -51
  51. package/src/dto/reset-password.dto.ts +0 -115
  52. package/src/dto/respond-challenge.dto.ts +0 -272
  53. package/src/dto/set-mfa-exemption.dto.ts +0 -112
  54. package/src/dto/set-must-change-password-response.dto.ts +0 -27
  55. package/src/dto/set-must-change-password.dto.ts +0 -46
  56. package/src/dto/set-preferred-method.dto.ts +0 -80
  57. package/src/dto/setup-mfa.dto.ts +0 -98
  58. package/src/dto/signup.dto.ts +0 -174
  59. package/src/dto/social-auth.dto.ts +0 -422
  60. package/src/dto/trust-device-response.dto.ts +0 -30
  61. package/src/dto/trust-device.dto.ts +0 -9
  62. package/src/dto/update-user-attributes-request.dto.ts +0 -51
  63. package/src/dto/user-response.dto.ts +0 -138
  64. package/src/dto/user-update.dto.ts +0 -222
  65. package/src/dto/verify-email.dto.ts +0 -313
  66. package/src/dto/verify-mfa-code.dto.ts +0 -103
  67. package/src/dto/verify-phone-by-sub.dto.ts +0 -78
  68. package/src/dto/verify-phone.dto.ts +0 -245
  69. package/src/entities/auth-audit.entity.ts +0 -232
  70. package/src/entities/challenge-session.entity.ts +0 -116
  71. package/src/entities/index.ts +0 -29
  72. package/src/entities/login-attempt.entity.ts +0 -64
  73. package/src/entities/mfa-device.entity.ts +0 -151
  74. package/src/entities/rate-limit.entity.ts +0 -44
  75. package/src/entities/session.entity.ts +0 -180
  76. package/src/entities/social-account.entity.ts +0 -96
  77. package/src/entities/storage-lock.entity.ts +0 -39
  78. package/src/entities/trusted-device.entity.ts +0 -112
  79. package/src/entities/user.entity.ts +0 -243
  80. package/src/entities/verification-token.entity.ts +0 -141
  81. package/src/enums/auth-audit-event-type.enum.ts +0 -360
  82. package/src/enums/error-codes.enum.ts +0 -420
  83. package/src/enums/mfa-method.enum.ts +0 -97
  84. package/src/enums/risk-factor.enum.ts +0 -111
  85. package/src/exceptions/nauth.exception.ts +0 -231
  86. package/src/handlers/auth.handler.ts +0 -260
  87. package/src/handlers/client-info.handler.ts +0 -101
  88. package/src/handlers/csrf.handler.ts +0 -156
  89. package/src/handlers/token-delivery.handler.ts +0 -118
  90. package/src/index.ts +0 -118
  91. package/src/interfaces/client-info.interface.ts +0 -85
  92. package/src/interfaces/config.interface.ts +0 -2135
  93. package/src/interfaces/entities.interface.ts +0 -226
  94. package/src/interfaces/index.ts +0 -15
  95. package/src/interfaces/logger.interface.ts +0 -283
  96. package/src/interfaces/mfa-provider.interface.ts +0 -154
  97. package/src/interfaces/oauth.interface.ts +0 -148
  98. package/src/interfaces/provider.interface.ts +0 -47
  99. package/src/interfaces/social-auth-provider.interface.ts +0 -131
  100. package/src/interfaces/storage-adapter.interface.ts +0 -82
  101. package/src/interfaces/template.interface.ts +0 -510
  102. package/src/interfaces/token-verifier.interface.ts +0 -110
  103. package/src/internal.ts +0 -178
  104. package/src/platform/interfaces.ts +0 -299
  105. package/src/schemas/auth-config.schema.ts +0 -646
  106. package/src/services/adaptive-mfa-decision.service.spec.ts +0 -1058
  107. package/src/services/adaptive-mfa-decision.service.ts +0 -457
  108. package/src/services/auth-audit.service.spec.ts +0 -675
  109. package/src/services/auth-audit.service.ts +0 -558
  110. package/src/services/auth-challenge-helper.service.spec.ts +0 -3227
  111. package/src/services/auth-challenge-helper.service.ts +0 -825
  112. package/src/services/auth-flow-context-builder.service.ts +0 -520
  113. package/src/services/auth-flow-rules.ts +0 -202
  114. package/src/services/auth-flow-state-definitions.ts +0 -190
  115. package/src/services/auth-flow-state-machine.service.ts +0 -207
  116. package/src/services/auth-flow-state-machine.types.ts +0 -316
  117. package/src/services/auth.service.spec.ts +0 -4195
  118. package/src/services/auth.service.ts +0 -3727
  119. package/src/services/challenge.service.spec.ts +0 -1363
  120. package/src/services/challenge.service.ts +0 -696
  121. package/src/services/client-info.service.spec.ts +0 -572
  122. package/src/services/client-info.service.ts +0 -374
  123. package/src/services/csrf.service.ts +0 -54
  124. package/src/services/email-verification.service.spec.ts +0 -1229
  125. package/src/services/email-verification.service.ts +0 -578
  126. package/src/services/geo-location.service.spec.ts +0 -603
  127. package/src/services/geo-location.service.ts +0 -599
  128. package/src/services/index.ts +0 -13
  129. package/src/services/jwt.service.spec.ts +0 -882
  130. package/src/services/jwt.service.ts +0 -621
  131. package/src/services/mfa-base.service.spec.ts +0 -246
  132. package/src/services/mfa-base.service.ts +0 -611
  133. package/src/services/mfa.service.spec.ts +0 -693
  134. package/src/services/mfa.service.ts +0 -960
  135. package/src/services/password.service.spec.ts +0 -166
  136. package/src/services/password.service.ts +0 -309
  137. package/src/services/phone-verification.service.spec.ts +0 -1120
  138. package/src/services/phone-verification.service.ts +0 -751
  139. package/src/services/risk-detection.service.spec.ts +0 -1292
  140. package/src/services/risk-detection.service.ts +0 -1012
  141. package/src/services/risk-scoring.service.spec.ts +0 -204
  142. package/src/services/risk-scoring.service.ts +0 -131
  143. package/src/services/session.service.spec.ts +0 -1293
  144. package/src/services/session.service.ts +0 -803
  145. package/src/services/social-account.service.spec.ts +0 -725
  146. package/src/services/social-auth-base.service.spec.ts +0 -418
  147. package/src/services/social-auth-base.service.ts +0 -581
  148. package/src/services/social-auth.service.spec.ts +0 -238
  149. package/src/services/social-auth.service.ts +0 -436
  150. package/src/services/social-provider-registry.service.spec.ts +0 -238
  151. package/src/services/social-provider-registry.service.ts +0 -122
  152. package/src/services/trusted-device.service.spec.ts +0 -505
  153. package/src/services/trusted-device.service.ts +0 -339
  154. package/src/storage/account-lockout-storage.service.spec.ts +0 -310
  155. package/src/storage/account-lockout-storage.service.ts +0 -89
  156. package/src/storage/index.ts +0 -3
  157. package/src/storage/memory-storage.adapter.ts +0 -443
  158. package/src/storage/rate-limit-storage.service.spec.ts +0 -247
  159. package/src/storage/rate-limit-storage.service.ts +0 -38
  160. package/src/templates/html-template.engine.spec.ts +0 -161
  161. package/src/templates/html-template.engine.ts +0 -688
  162. package/src/templates/index.ts +0 -7
  163. package/src/utils/common-passwords.spec.ts +0 -230
  164. package/src/utils/common-passwords.ts +0 -170
  165. package/src/utils/context-storage.ts +0 -188
  166. package/src/utils/cookie-names.util.ts +0 -67
  167. package/src/utils/cookies.util.ts +0 -94
  168. package/src/utils/index.ts +0 -12
  169. package/src/utils/ip-extractor.spec.ts +0 -330
  170. package/src/utils/ip-extractor.ts +0 -220
  171. package/src/utils/nauth-logger.spec.ts +0 -388
  172. package/src/utils/nauth-logger.ts +0 -215
  173. package/src/utils/pii-redactor.spec.ts +0 -130
  174. package/src/utils/pii-redactor.ts +0 -288
  175. package/src/utils/setup/get-repositories.ts +0 -140
  176. package/src/utils/setup/init-services.ts +0 -422
  177. package/src/utils/setup/init-social.ts +0 -189
  178. package/src/utils/setup/init-storage.ts +0 -94
  179. package/src/utils/setup/register-mfa.ts +0 -165
  180. package/src/utils/setup/run-nauth-migrations.ts +0 -61
  181. package/src/utils/token-delivery-policy.ts +0 -38
  182. package/src/validators/template.validator.ts +0 -219
  183. package/tsconfig.json +0 -37
  184. package/tsconfig.lint.json +0 -6
@@ -1,1120 +0,0 @@
1
- import { Repository } from 'typeorm';
2
- import { PhoneVerificationService } from './phone-verification.service';
3
- import { NAuthException } from '../exceptions/nauth.exception';
4
- import { ClientInfoService } from './client-info.service';
5
- import { SMSProvider } from '../interfaces/provider.interface';
6
- import { StorageAdapter } from '../interfaces/storage-adapter.interface';
7
- import { NAuthConfig } from '../interfaces/config.interface';
8
- import { NAuthLogger } from '../utils/nauth-logger';
9
- import { AuthAuditService } from './auth-audit.service';
10
- import { BaseVerificationToken, BaseUser } from '../entities';
11
- import { IUser, IVerificationToken } from '../interfaces/entities.interface';
12
- import { AuthErrorCode } from '../enums/error-codes.enum';
13
-
14
- /**
15
- * Phone Verification Service Unit Tests
16
- *
17
- * Platform-agnostic: Uses direct instantiation, no NestJS dependencies.
18
- *
19
- * Covers:
20
- * - Send verification SMS with rate limiting
21
- * - Code-based verification (by phone and by sub)
22
- * - Resend verification SMS with cooldown
23
- * - Rate limiting per user
24
- * - Error handling for all dependencies
25
- * - Storage adapter failures
26
- * - SMS provider errors
27
- */
28
- describe('PhoneVerificationService', () => {
29
- let service: PhoneVerificationService;
30
- let mockVerificationTokenRepository: jest.Mocked<Repository<BaseVerificationToken>>;
31
- let mockUserRepository: jest.Mocked<Repository<BaseUser>>;
32
- let mockSmsProvider: jest.Mocked<SMSProvider>;
33
- let mockStorageAdapter: jest.Mocked<StorageAdapter>;
34
- let mockClientInfoService: jest.Mocked<ClientInfoService>;
35
- let mockLogger: jest.Mocked<NAuthLogger>;
36
- let mockAuditService: jest.Mocked<AuthAuditService>;
37
- let mockConfig: NAuthConfig;
38
-
39
- const mockUser: IUser = {
40
- id: 123,
41
- sub: 'user-sub-123',
42
- email: 'test@example.com',
43
- username: 'testuser',
44
- phone: '+1234567890',
45
- firstName: null,
46
- lastName: null,
47
- passwordHash: null,
48
- passwordChangedAt: null,
49
- passwordHistory: null,
50
- isEmailVerified: false,
51
- isPhoneVerified: false,
52
- isActive: true,
53
- mustChangePassword: false,
54
- isLocked: false,
55
- lockReason: null,
56
- lockedAt: null,
57
- lockedUntil: null,
58
- failedLoginAttempts: 0,
59
- lastFailedLoginAt: null,
60
- lastLoginAt: null,
61
- lastLoginIp: null,
62
- hasSocialAuth: false,
63
- socialProviders: null,
64
- mfaEnabled: false,
65
- mfaMethods: null,
66
- preferredMfaMethod: null,
67
- backupCodes: null,
68
- metadata: null,
69
- createdAt: new Date(),
70
- updatedAt: new Date(),
71
- deletedAt: null,
72
- };
73
-
74
- const mockVerificationToken: IVerificationToken = {
75
- id: 456,
76
- userId: 123,
77
- type: 'phone',
78
- token: 'hashed-token-abc123',
79
- code: '123456',
80
- expiresAt: new Date(Date.now() + 300000), // 5 minutes from now
81
- attempts: 0,
82
- usedAt: null,
83
- ipAddress: '127.0.0.1',
84
- userAgent: 'test-agent',
85
- createdAt: new Date(),
86
- isExpired: jest.fn().mockReturnValue(false),
87
- maxAttemptsExceeded: jest.fn().mockReturnValue(false),
88
- };
89
-
90
- beforeEach(() => {
91
- mockVerificationTokenRepository = {
92
- create: jest.fn(),
93
- save: jest.fn(),
94
- findOne: jest.fn(),
95
- find: jest.fn(),
96
- update: jest.fn().mockResolvedValue({ affected: 0 } as any),
97
- count: jest.fn(),
98
- } as any;
99
-
100
- mockUserRepository = {
101
- findOne: jest.fn(),
102
- save: jest.fn(),
103
- update: jest.fn().mockResolvedValue({ affected: 1 } as any),
104
- } as any;
105
-
106
- mockSmsProvider = {
107
- sendOTP: jest.fn().mockResolvedValue(undefined),
108
- setLogger: jest.fn(),
109
- } as any;
110
-
111
- mockStorageAdapter = {
112
- get: jest.fn(),
113
- set: jest.fn(),
114
- incr: jest.fn().mockResolvedValue(1),
115
- expire: jest.fn().mockResolvedValue(undefined),
116
- ttl: jest.fn().mockResolvedValue(3600),
117
- del: jest.fn().mockResolvedValue(undefined),
118
- exists: jest.fn(),
119
- initialize: jest.fn(),
120
- isHealthy: jest.fn().mockResolvedValue(true),
121
- cleanup: jest.fn(),
122
- disconnect: jest.fn(),
123
- } as any;
124
-
125
- mockClientInfoService = {
126
- get: jest.fn().mockReturnValue({
127
- ipAddress: '127.0.0.1',
128
- userAgent: 'test-agent',
129
- }),
130
- } as any;
131
-
132
- mockLogger = {
133
- log: jest.fn(),
134
- error: jest.fn(),
135
- warn: jest.fn(),
136
- debug: jest.fn(),
137
- verbose: jest.fn(),
138
- } as any;
139
-
140
- mockAuditService = {
141
- recordEvent: jest.fn().mockResolvedValue(null),
142
- } as any;
143
-
144
- mockConfig = {
145
- jwt: {
146
- accessToken: { secret: 'test-secret', expiresIn: '15m' },
147
- refreshToken: { secret: 'test-refresh-secret', expiresIn: '7d' },
148
- },
149
- signup: {
150
- phoneVerification: {
151
- codeLength: 6,
152
- expiresIn: 300,
153
- maxAttempts: 3,
154
- resendDelay: 60,
155
- rateLimitMax: 3,
156
- rateLimitWindow: 3600,
157
- },
158
- },
159
- };
160
-
161
- // Instantiate service directly
162
- service = new PhoneVerificationService(
163
- mockVerificationTokenRepository,
164
- mockUserRepository,
165
- mockSmsProvider,
166
- mockStorageAdapter,
167
- mockConfig,
168
- mockClientInfoService,
169
- mockLogger,
170
- mockAuditService,
171
- );
172
- });
173
-
174
- afterEach(() => {
175
- jest.clearAllMocks();
176
- });
177
-
178
- // ============================================================================
179
- // Service Initialization
180
- // ============================================================================
181
-
182
- it('should be defined', () => {
183
- expect(service).toBeDefined();
184
- });
185
-
186
- // ============================================================================
187
- // sendVerificationSMS
188
- // ============================================================================
189
-
190
- describe('sendVerificationSMS', () => {
191
- it('should send verification SMS successfully', async () => {
192
- mockStorageAdapter.incr.mockResolvedValue(1);
193
- mockStorageAdapter.ttl.mockResolvedValue(3600);
194
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
195
- mockVerificationTokenRepository.findOne.mockResolvedValue(null); // No last token
196
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
197
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
198
-
199
- const result = await service.sendVerificationSMS('user-sub-123');
200
-
201
- expect(mockStorageAdapter.incr).toHaveBeenCalled();
202
- expect(mockUserRepository.findOne).toHaveBeenCalledWith({ where: { sub: 'user-sub-123' } as any });
203
- expect(mockVerificationTokenRepository.save).toHaveBeenCalled();
204
- expect(mockSmsProvider.sendOTP).toHaveBeenCalledWith('+1234567890', (expect as any).any(String));
205
- expect(mockAuditService.recordEvent).toHaveBeenCalled();
206
- expect(result).toBe(456);
207
- });
208
-
209
- it('should throw NAuthException if user not found', async () => {
210
- mockStorageAdapter.incr.mockResolvedValue(1);
211
- mockStorageAdapter.ttl.mockResolvedValue(3600);
212
- mockUserRepository.findOne.mockResolvedValue(null);
213
-
214
- try {
215
- await service.sendVerificationSMS('invalid-sub');
216
- fail('Should have thrown NAuthException');
217
- } catch (error: any) {
218
- expect(error).toBeInstanceOf(NAuthException);
219
- expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
220
- }
221
- });
222
-
223
- it('should throw NAuthException if phone not provided', async () => {
224
- const userWithoutPhone = { ...mockUser, phone: null };
225
- mockStorageAdapter.incr.mockResolvedValue(1);
226
- mockStorageAdapter.ttl.mockResolvedValue(3600);
227
- mockUserRepository.findOne.mockResolvedValue(userWithoutPhone as any);
228
-
229
- try {
230
- await service.sendVerificationSMS('user-sub-123');
231
- fail('Should have thrown NAuthException');
232
- } catch (error: any) {
233
- expect(error).toBeInstanceOf(NAuthException);
234
- expect(error.code).toBe(AuthErrorCode.PHONE_REQUIRED);
235
- }
236
- });
237
-
238
- it('should throw NAuthException if phone already verified (when skipAlreadyVerifiedCheck is false)', async () => {
239
- const verifiedUser = { ...mockUser, isPhoneVerified: true };
240
- mockStorageAdapter.incr.mockResolvedValue(1);
241
- mockStorageAdapter.ttl.mockResolvedValue(3600);
242
- mockUserRepository.findOne.mockResolvedValue(verifiedUser as any);
243
-
244
- try {
245
- await service.sendVerificationSMS('user-sub-123', false); // Don't skip check
246
- fail('Should have thrown NAuthException');
247
- } catch (error: any) {
248
- expect(error).toBeInstanceOf(NAuthException);
249
- expect(error.code).toBe(AuthErrorCode.ALREADY_VERIFIED);
250
- }
251
- });
252
-
253
- it('should allow sending SMS when phone already verified (skipAlreadyVerifiedCheck is true)', async () => {
254
- const verifiedUser = { ...mockUser, isPhoneVerified: true };
255
- mockStorageAdapter.incr.mockResolvedValue(1);
256
- mockStorageAdapter.ttl.mockResolvedValue(3600);
257
- mockUserRepository.findOne.mockResolvedValue(verifiedUser as any);
258
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
259
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
260
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
261
-
262
- await service.sendVerificationSMS('user-sub-123', true); // Skip check
263
-
264
- expect(mockSmsProvider.sendOTP).toHaveBeenCalled();
265
- });
266
-
267
- it('should enforce rate limit (too many SMS)', async () => {
268
- mockStorageAdapter.incr.mockResolvedValue(4); // Exceeds limit of 3
269
- mockStorageAdapter.ttl.mockResolvedValue(3600);
270
-
271
- try {
272
- await service.sendVerificationSMS('user-sub-123');
273
- fail('Should have thrown NAuthException');
274
- } catch (error: any) {
275
- expect(error).toBeInstanceOf(NAuthException);
276
- expect(error.code).toBe(AuthErrorCode.RATE_LIMIT_SMS);
277
- expect(mockUserRepository.findOne).not.toHaveBeenCalled();
278
- }
279
- });
280
-
281
- it('should enforce resend delay', async () => {
282
- const recentToken = {
283
- ...mockVerificationToken,
284
- createdAt: new Date(Date.now() - 30 * 1000), // 30 seconds ago (less than 60s delay)
285
- };
286
- mockStorageAdapter.incr.mockResolvedValue(1);
287
- mockStorageAdapter.ttl.mockResolvedValue(3600);
288
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
289
- mockVerificationTokenRepository.findOne.mockResolvedValue(recentToken as any);
290
-
291
- try {
292
- await service.sendVerificationSMS('user-sub-123');
293
- fail('Should have thrown NAuthException');
294
- } catch (error: any) {
295
- expect(error).toBeInstanceOf(NAuthException);
296
- expect(error.code).toBe(AuthErrorCode.RATE_LIMIT_RESEND);
297
- }
298
- });
299
-
300
- it('should allow resend after delay period', async () => {
301
- const oldToken = {
302
- ...mockVerificationToken,
303
- createdAt: new Date(Date.now() - 70 * 1000), // 70 seconds ago (more than 60s delay)
304
- };
305
- mockStorageAdapter.incr.mockResolvedValue(1);
306
- mockStorageAdapter.ttl.mockResolvedValue(3600);
307
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
308
- mockVerificationTokenRepository.findOne.mockResolvedValue(oldToken as any);
309
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
310
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
311
-
312
- await service.sendVerificationSMS('user-sub-123');
313
-
314
- expect(mockSmsProvider.sendOTP).toHaveBeenCalled();
315
- });
316
-
317
- it('should invalidate existing unused tokens', async () => {
318
- mockStorageAdapter.incr.mockResolvedValue(1);
319
- mockStorageAdapter.ttl.mockResolvedValue(3600);
320
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
321
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
322
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
323
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
324
-
325
- await service.sendVerificationSMS('user-sub-123');
326
-
327
- expect(mockVerificationTokenRepository.update).toHaveBeenCalledWith(
328
- (expect as any).objectContaining({
329
- userId: 123,
330
- type: 'phone',
331
- }),
332
- (expect as any).objectContaining({
333
- usedAt: (expect as any).any(Date),
334
- }),
335
- );
336
- });
337
-
338
- it('should handle rate limit window reset when TTL > window', async () => {
339
- mockStorageAdapter.ttl.mockResolvedValue(7200); // TTL longer than window (3600)
340
- mockStorageAdapter.del.mockResolvedValue(undefined);
341
- mockStorageAdapter.incr.mockResolvedValue(1);
342
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
343
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
344
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
345
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
346
-
347
- await service.sendVerificationSMS('user-sub-123');
348
-
349
- expect(mockStorageAdapter.del).toHaveBeenCalledWith('phone-verification:user-sub-123');
350
- });
351
-
352
- it('should handle SMS provider errors', async () => {
353
- mockStorageAdapter.incr.mockResolvedValue(1);
354
- mockStorageAdapter.ttl.mockResolvedValue(3600);
355
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
356
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
357
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
358
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
359
- mockSmsProvider.sendOTP.mockRejectedValue(new Error('SMS service error'));
360
-
361
- try {
362
- await service.sendVerificationSMS('user-sub-123');
363
- fail('Should have thrown error');
364
- } catch (error: any) {
365
- expect(error.message).toContain('SMS service error');
366
- }
367
- });
368
-
369
- it('should handle audit service errors gracefully', async () => {
370
- mockStorageAdapter.incr.mockResolvedValue(1);
371
- mockStorageAdapter.ttl.mockResolvedValue(3600);
372
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
373
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
374
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
375
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
376
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
377
-
378
- await service.sendVerificationSMS('user-sub-123');
379
-
380
- expect(mockLogger.error).toHaveBeenCalled();
381
- expect(mockSmsProvider.sendOTP).toHaveBeenCalled(); // Should still send SMS
382
- });
383
-
384
- it('should use custom rate limit config', async () => {
385
- mockConfig.signup!.phoneVerification!.rateLimitMax = 5;
386
- mockConfig.signup!.phoneVerification!.rateLimitWindow = 1800;
387
- service = new PhoneVerificationService(
388
- mockVerificationTokenRepository,
389
- mockUserRepository,
390
- mockSmsProvider,
391
- mockStorageAdapter,
392
- mockConfig,
393
- mockClientInfoService,
394
- mockLogger,
395
- mockAuditService,
396
- );
397
-
398
- mockStorageAdapter.incr.mockResolvedValue(6); // Exceeds new limit of 5
399
- mockStorageAdapter.ttl.mockResolvedValue(1800);
400
-
401
- try {
402
- await service.sendVerificationSMS('user-sub-123');
403
- fail('Should have thrown NAuthException');
404
- } catch (error: any) {
405
- expect(error.code).toBe(AuthErrorCode.RATE_LIMIT_SMS);
406
- }
407
- });
408
-
409
- it('should use custom resend delay config', async () => {
410
- mockConfig.signup!.phoneVerification!.resendDelay = 120; // 2 minutes
411
- service = new PhoneVerificationService(
412
- mockVerificationTokenRepository,
413
- mockUserRepository,
414
- mockSmsProvider,
415
- mockStorageAdapter,
416
- mockConfig,
417
- mockClientInfoService,
418
- mockLogger,
419
- mockAuditService,
420
- );
421
-
422
- const recentToken = {
423
- ...mockVerificationToken,
424
- createdAt: new Date(Date.now() - 90 * 1000), // 90 seconds ago (less than 120s)
425
- };
426
- mockStorageAdapter.incr.mockResolvedValue(1);
427
- mockStorageAdapter.ttl.mockResolvedValue(3600);
428
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
429
- mockVerificationTokenRepository.findOne.mockResolvedValue(recentToken as any);
430
-
431
- try {
432
- await service.sendVerificationSMS('user-sub-123');
433
- fail('Should have thrown NAuthException');
434
- } catch (error: any) {
435
- expect(error.code).toBe(AuthErrorCode.RATE_LIMIT_RESEND);
436
- }
437
- });
438
- });
439
-
440
- // ============================================================================
441
- // verifyPhoneWithCode
442
- // ============================================================================
443
-
444
- describe('verifyPhoneWithCode', () => {
445
- it('should verify phone with valid code', async () => {
446
- mockVerificationTokenRepository.find.mockResolvedValue([mockVerificationToken] as any);
447
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
448
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
449
-
450
- const result = await service.verifyPhoneWithCode('+1234567890', '123456');
451
-
452
- expect(result.message).toBe('Phone verified successfully. Please log in to continue.');
453
- expect(mockUserRepository.update).toHaveBeenCalledWith(123, {
454
- isPhoneVerified: true,
455
- isActive: true,
456
- });
457
- expect(mockAuditService.recordEvent).toHaveBeenCalled();
458
- });
459
-
460
- it('should throw NAuthException for invalid code', async () => {
461
- mockVerificationTokenRepository.find.mockResolvedValue([]); // No matching tokens
462
-
463
- try {
464
- await service.verifyPhoneWithCode('+1234567890', 'wrong-code');
465
- fail('Should have thrown NAuthException');
466
- } catch (error: any) {
467
- expect(error).toBeInstanceOf(NAuthException);
468
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_CODE_INVALID);
469
- }
470
- });
471
-
472
- it('should throw NAuthException when no matching user phone', async () => {
473
- const tokenForDifferentUser = {
474
- ...mockVerificationToken,
475
- userId: 999,
476
- };
477
- const differentUser = {
478
- ...mockUser,
479
- id: 999,
480
- phone: '+9999999999', // Different phone
481
- };
482
- mockVerificationTokenRepository.find.mockResolvedValue([tokenForDifferentUser] as any);
483
- mockUserRepository.findOne.mockResolvedValue(differentUser as any);
484
-
485
- try {
486
- await service.verifyPhoneWithCode('+1234567890', '123456');
487
- fail('Should have thrown NAuthException');
488
- } catch (error: any) {
489
- expect(error).toBeInstanceOf(NAuthException);
490
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_CODE_INVALID);
491
- }
492
- });
493
-
494
- it('should throw NAuthException for expired code', async () => {
495
- const expiredToken = {
496
- ...mockVerificationToken,
497
- expiresAt: new Date(Date.now() - 1000),
498
- isExpired: jest.fn().mockReturnValue(true),
499
- };
500
- mockVerificationTokenRepository.find.mockResolvedValue([expiredToken] as any);
501
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
502
-
503
- try {
504
- await service.verifyPhoneWithCode('+1234567890', '123456');
505
- fail('Should have thrown NAuthException');
506
- } catch (error: any) {
507
- expect(error).toBeInstanceOf(NAuthException);
508
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_CODE_EXPIRED);
509
- }
510
- });
511
-
512
- it('should throw NAuthException after max attempts', async () => {
513
- const exhaustedToken = {
514
- ...mockVerificationToken,
515
- attempts: 3,
516
- isExpired: jest.fn().mockReturnValue(false),
517
- maxAttemptsExceeded: jest.fn().mockReturnValue(true),
518
- };
519
- mockVerificationTokenRepository.find.mockResolvedValue([exhaustedToken] as any);
520
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
521
-
522
- try {
523
- await service.verifyPhoneWithCode('+1234567890', '123456');
524
- fail('Should have thrown NAuthException');
525
- } catch (error: any) {
526
- expect(error).toBeInstanceOf(NAuthException);
527
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS);
528
- }
529
- });
530
-
531
- it('should increment attempts on invalid code', async () => {
532
- const tokenWithWrongCode = {
533
- ...mockVerificationToken,
534
- code: '999999', // Different code
535
- attempts: 0,
536
- };
537
- mockVerificationTokenRepository.find.mockResolvedValue([tokenWithWrongCode] as any);
538
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
539
- mockVerificationTokenRepository.save.mockResolvedValue(tokenWithWrongCode as any);
540
-
541
- try {
542
- await service.verifyPhoneWithCode('+1234567890', '123456');
543
- fail('Should have thrown NAuthException');
544
- } catch (error: any) {
545
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_CODE_INVALID);
546
- expect(mockVerificationTokenRepository.save).toHaveBeenCalledWith(
547
- (expect as any).objectContaining({
548
- attempts: 1, // Incremented
549
- }),
550
- );
551
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
552
- (expect as any).objectContaining({
553
- eventType: (expect as any).any(String),
554
- eventStatus: 'FAILURE',
555
- }),
556
- );
557
- }
558
- });
559
-
560
- it('should handle multiple tokens with same code and select correct user', async () => {
561
- const token1 = {
562
- ...mockVerificationToken,
563
- userId: 123,
564
- code: '123456',
565
- };
566
- const token2 = {
567
- ...mockVerificationToken,
568
- userId: 456,
569
- code: '123456',
570
- };
571
- const user2 = {
572
- ...mockUser,
573
- id: 456,
574
- phone: '+9876543210',
575
- };
576
- mockVerificationTokenRepository.find.mockResolvedValue([token1, token2] as any);
577
- mockUserRepository.findOne
578
- .mockResolvedValueOnce(mockUser as any) // First call for user 123
579
- .mockResolvedValueOnce(user2 as any); // Second call for user 456
580
-
581
- const result = await service.verifyPhoneWithCode('+1234567890', '123456');
582
-
583
- // Should match token1 (user 123) because phone matches
584
- expect(result.message).toBeDefined();
585
- expect(mockUserRepository.update).toHaveBeenCalledWith(123, (expect as any).any(Object));
586
- });
587
-
588
- it('should handle audit service errors gracefully', async () => {
589
- mockVerificationTokenRepository.find.mockResolvedValue([mockVerificationToken] as any);
590
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
591
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
592
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
593
-
594
- const result = await service.verifyPhoneWithCode('+1234567890', '123456');
595
-
596
- expect(mockLogger.error).toHaveBeenCalled();
597
- expect(result.message).toBeDefined(); // Should still verify
598
- });
599
-
600
- it('should check expiration using isExpired method if available', async () => {
601
- const tokenWithMethod = {
602
- ...mockVerificationToken,
603
- isExpired: jest.fn().mockReturnValue(true),
604
- };
605
- mockVerificationTokenRepository.find.mockResolvedValue([tokenWithMethod] as any);
606
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
607
-
608
- try {
609
- await service.verifyPhoneWithCode('+1234567890', '123456');
610
- fail('Should have thrown NAuthException');
611
- } catch (error: any) {
612
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_CODE_EXPIRED);
613
- expect(tokenWithMethod.isExpired).toHaveBeenCalled();
614
- }
615
- });
616
-
617
- it('should check expiration using expiresAt date if method not available', async () => {
618
- const tokenWithoutMethod = {
619
- ...mockVerificationToken,
620
- expiresAt: new Date(Date.now() - 1000), // Expired
621
- isExpired: undefined,
622
- };
623
- mockVerificationTokenRepository.find.mockResolvedValue([tokenWithoutMethod] as any);
624
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
625
-
626
- try {
627
- await service.verifyPhoneWithCode('+1234567890', '123456');
628
- fail('Should have thrown NAuthException');
629
- } catch (error: any) {
630
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_CODE_EXPIRED);
631
- }
632
- });
633
-
634
- it('should check max attempts using method if available', async () => {
635
- const tokenWithMethod = {
636
- ...mockVerificationToken,
637
- maxAttemptsExceeded: jest.fn().mockReturnValue(true),
638
- };
639
- mockVerificationTokenRepository.find.mockResolvedValue([tokenWithMethod] as any);
640
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
641
-
642
- try {
643
- await service.verifyPhoneWithCode('+1234567890', '123456');
644
- fail('Should have thrown NAuthException');
645
- } catch (error: any) {
646
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS);
647
- expect(tokenWithMethod.maxAttemptsExceeded).toHaveBeenCalledWith(3);
648
- }
649
- });
650
-
651
- it('should check max attempts using attempts field if method not available', async () => {
652
- const tokenWithoutMethod = {
653
- ...mockVerificationToken,
654
- attempts: 3,
655
- maxAttemptsExceeded: undefined,
656
- };
657
- mockVerificationTokenRepository.find.mockResolvedValue([tokenWithoutMethod] as any);
658
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
659
-
660
- try {
661
- await service.verifyPhoneWithCode('+1234567890', '123456');
662
- fail('Should have thrown NAuthException');
663
- } catch (error: any) {
664
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS);
665
- }
666
- });
667
- });
668
-
669
- // ============================================================================
670
- // verifyPhoneWithCodeBySub
671
- // ============================================================================
672
-
673
- describe('verifyPhoneWithCodeBySub', () => {
674
- it('should verify phone with valid code by sub', async () => {
675
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
676
- mockVerificationTokenRepository.findOne.mockResolvedValue(mockVerificationToken as any);
677
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
678
-
679
- const result = await service.verifyPhoneWithCodeBySub('user-sub-123', '123456');
680
-
681
- expect(result.message).toBe('Phone verified successfully. Please log in to continue.');
682
- expect(mockUserRepository.update).toHaveBeenCalledWith({ sub: 'user-sub-123' } as any, {
683
- isPhoneVerified: true,
684
- isActive: true,
685
- });
686
- expect(mockAuditService.recordEvent).toHaveBeenCalled();
687
- });
688
-
689
- it('should throw NAuthException if user not found', async () => {
690
- mockUserRepository.findOne.mockResolvedValue(null);
691
-
692
- try {
693
- await service.verifyPhoneWithCodeBySub('invalid-sub', '123456');
694
- fail('Should have thrown NAuthException');
695
- } catch (error: any) {
696
- expect(error).toBeInstanceOf(NAuthException);
697
- expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
698
- }
699
- });
700
-
701
- it('should throw NAuthException if phone not provided', async () => {
702
- const userWithoutPhone = { ...mockUser, phone: null };
703
- mockUserRepository.findOne.mockResolvedValue(userWithoutPhone as any);
704
-
705
- try {
706
- await service.verifyPhoneWithCodeBySub('user-sub-123', '123456');
707
- fail('Should have thrown NAuthException');
708
- } catch (error: any) {
709
- expect(error).toBeInstanceOf(NAuthException);
710
- expect(error.code).toBe(AuthErrorCode.PHONE_REQUIRED);
711
- }
712
- });
713
-
714
- it('should throw NAuthException for invalid code', async () => {
715
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
716
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
717
-
718
- try {
719
- await service.verifyPhoneWithCodeBySub('user-sub-123', 'wrong-code');
720
- fail('Should have thrown NAuthException');
721
- } catch (error: any) {
722
- expect(error).toBeInstanceOf(NAuthException);
723
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_CODE_INVALID);
724
- }
725
- });
726
-
727
- it('should throw NAuthException for expired code', async () => {
728
- const expiredToken = {
729
- ...mockVerificationToken,
730
- expiresAt: new Date(Date.now() - 1000),
731
- isExpired: jest.fn().mockReturnValue(true),
732
- };
733
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
734
- mockVerificationTokenRepository.findOne.mockResolvedValue(expiredToken as any);
735
-
736
- try {
737
- await service.verifyPhoneWithCodeBySub('user-sub-123', '123456');
738
- fail('Should have thrown NAuthException');
739
- } catch (error: any) {
740
- expect(error).toBeInstanceOf(NAuthException);
741
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_CODE_EXPIRED);
742
- }
743
- });
744
-
745
- it('should throw NAuthException after max attempts', async () => {
746
- const exhaustedToken = {
747
- ...mockVerificationToken,
748
- attempts: 3,
749
- isExpired: jest.fn().mockReturnValue(false),
750
- maxAttemptsExceeded: jest.fn().mockReturnValue(true),
751
- };
752
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
753
- mockVerificationTokenRepository.findOne.mockResolvedValue(exhaustedToken as any);
754
-
755
- try {
756
- await service.verifyPhoneWithCodeBySub('user-sub-123', '123456');
757
- fail('Should have thrown NAuthException');
758
- } catch (error: any) {
759
- expect(error).toBeInstanceOf(NAuthException);
760
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS);
761
- }
762
- });
763
-
764
- it('should increment attempts on invalid code', async () => {
765
- const tokenWithWrongCode = {
766
- ...mockVerificationToken,
767
- code: '999999', // Different code
768
- attempts: 0,
769
- };
770
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
771
- mockVerificationTokenRepository.findOne.mockResolvedValue(tokenWithWrongCode as any);
772
- mockVerificationTokenRepository.save.mockResolvedValue(tokenWithWrongCode as any);
773
-
774
- try {
775
- await service.verifyPhoneWithCodeBySub('user-sub-123', '123456');
776
- fail('Should have thrown NAuthException');
777
- } catch (error: any) {
778
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_CODE_INVALID);
779
- expect(mockVerificationTokenRepository.save).toHaveBeenCalledWith(
780
- (expect as any).objectContaining({
781
- attempts: 1, // Incremented
782
- }),
783
- );
784
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
785
- (expect as any).objectContaining({
786
- eventType: (expect as any).any(String),
787
- eventStatus: 'FAILURE',
788
- }),
789
- );
790
- }
791
- });
792
-
793
- it('should handle audit service errors gracefully', async () => {
794
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
795
- mockVerificationTokenRepository.findOne.mockResolvedValue(mockVerificationToken as any);
796
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
797
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
798
-
799
- const result = await service.verifyPhoneWithCodeBySub('user-sub-123', '123456');
800
-
801
- expect(mockLogger.error).toHaveBeenCalled();
802
- expect(result.message).toBeDefined(); // Should still verify
803
- });
804
-
805
- it('should check expiration using isExpired method if available', async () => {
806
- const tokenWithMethod = {
807
- ...mockVerificationToken,
808
- isExpired: jest.fn().mockReturnValue(true),
809
- };
810
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
811
- mockVerificationTokenRepository.findOne.mockResolvedValue(tokenWithMethod as any);
812
-
813
- try {
814
- await service.verifyPhoneWithCodeBySub('user-sub-123', '123456');
815
- fail('Should have thrown NAuthException');
816
- } catch (error: any) {
817
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_CODE_EXPIRED);
818
- expect(tokenWithMethod.isExpired).toHaveBeenCalled();
819
- }
820
- });
821
-
822
- it('should check expiration using expiresAt date if method not available', async () => {
823
- const tokenWithoutMethod = {
824
- ...mockVerificationToken,
825
- expiresAt: new Date(Date.now() - 1000), // Expired
826
- isExpired: undefined,
827
- };
828
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
829
- mockVerificationTokenRepository.findOne.mockResolvedValue(tokenWithoutMethod as any);
830
-
831
- try {
832
- await service.verifyPhoneWithCodeBySub('user-sub-123', '123456');
833
- fail('Should have thrown NAuthException');
834
- } catch (error: any) {
835
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_CODE_EXPIRED);
836
- }
837
- });
838
-
839
- it('should check max attempts using method if available', async () => {
840
- const tokenWithMethod = {
841
- ...mockVerificationToken,
842
- maxAttemptsExceeded: jest.fn().mockReturnValue(true),
843
- };
844
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
845
- mockVerificationTokenRepository.findOne.mockResolvedValue(tokenWithMethod as any);
846
-
847
- try {
848
- await service.verifyPhoneWithCodeBySub('user-sub-123', '123456');
849
- fail('Should have thrown NAuthException');
850
- } catch (error: any) {
851
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS);
852
- expect(tokenWithMethod.maxAttemptsExceeded).toHaveBeenCalledWith(3);
853
- }
854
- });
855
-
856
- it('should check max attempts using attempts field if method not available', async () => {
857
- const tokenWithoutMethod = {
858
- ...mockVerificationToken,
859
- attempts: 3,
860
- maxAttemptsExceeded: undefined,
861
- };
862
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
863
- mockVerificationTokenRepository.findOne.mockResolvedValue(tokenWithoutMethod as any);
864
-
865
- try {
866
- await service.verifyPhoneWithCodeBySub('user-sub-123', '123456');
867
- fail('Should have thrown NAuthException');
868
- } catch (error: any) {
869
- expect(error.code).toBe(AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS);
870
- }
871
- });
872
- });
873
-
874
- // ============================================================================
875
- // resendVerificationSMS
876
- // ============================================================================
877
-
878
- describe('resendVerificationSMS', () => {
879
- it('should resend verification SMS successfully', async () => {
880
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
881
- mockVerificationTokenRepository.findOne.mockResolvedValue(null); // No last token
882
- mockStorageAdapter.incr.mockResolvedValue(1);
883
- mockStorageAdapter.ttl.mockResolvedValue(3600);
884
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
885
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
886
-
887
- const result = await service.resendVerificationSMS('user-sub-123');
888
-
889
- expect(mockUserRepository.findOne).toHaveBeenCalledWith({ where: { sub: 'user-sub-123' } as any });
890
- expect(mockSmsProvider.sendOTP).toHaveBeenCalled();
891
- expect(result).toBe(456);
892
- });
893
-
894
- it('should enforce resend delay', async () => {
895
- const recentToken = {
896
- ...mockVerificationToken,
897
- createdAt: new Date(Date.now() - 30 * 1000), // 30 seconds ago
898
- };
899
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
900
- mockVerificationTokenRepository.findOne.mockResolvedValue(recentToken as any);
901
-
902
- try {
903
- await service.resendVerificationSMS('user-sub-123');
904
- fail('Should have thrown NAuthException');
905
- } catch (error: any) {
906
- expect(error).toBeInstanceOf(NAuthException);
907
- expect(error.code).toBe(AuthErrorCode.RATE_LIMIT_RESEND);
908
- }
909
- });
910
-
911
- it('should allow resend after delay period', async () => {
912
- const oldToken = {
913
- ...mockVerificationToken,
914
- createdAt: new Date(Date.now() - 70 * 1000), // 70 seconds ago
915
- };
916
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
917
- mockVerificationTokenRepository.findOne.mockResolvedValue(oldToken as any);
918
- mockStorageAdapter.incr.mockResolvedValue(1);
919
- mockStorageAdapter.ttl.mockResolvedValue(3600);
920
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
921
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
922
-
923
- await service.resendVerificationSMS('user-sub-123');
924
-
925
- expect(mockSmsProvider.sendOTP).toHaveBeenCalled();
926
- });
927
-
928
- it('should throw NAuthException if user not found', async () => {
929
- mockUserRepository.findOne.mockResolvedValue(null);
930
-
931
- try {
932
- await service.resendVerificationSMS('invalid-sub');
933
- fail('Should have thrown NAuthException');
934
- } catch (error: any) {
935
- expect(error).toBeInstanceOf(NAuthException);
936
- expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
937
- }
938
- });
939
-
940
- it('should delegate to sendVerificationSMS', async () => {
941
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
942
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
943
- mockStorageAdapter.incr.mockResolvedValue(1);
944
- mockStorageAdapter.ttl.mockResolvedValue(3600);
945
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
946
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
947
-
948
- await service.resendVerificationSMS('user-sub-123');
949
-
950
- // Should call sendVerificationSMS with same parameters
951
- expect(mockSmsProvider.sendOTP).toHaveBeenCalled();
952
- });
953
- });
954
-
955
- // ============================================================================
956
- // resendVerificationSMSForPhone
957
- // ============================================================================
958
-
959
- describe('resendVerificationSMSForPhone', () => {
960
- it('should resend verification SMS by phone number', async () => {
961
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
962
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
963
- mockStorageAdapter.incr.mockResolvedValue(1);
964
- mockStorageAdapter.ttl.mockResolvedValue(3600);
965
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
966
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
967
-
968
- const result = await service.resendVerificationSMSForPhone('+1234567890');
969
-
970
- expect(mockUserRepository.findOne).toHaveBeenCalledWith({ where: { phone: '+1234567890' } as any });
971
- expect(mockSmsProvider.sendOTP).toHaveBeenCalled();
972
- expect(result).toBe(456);
973
- });
974
-
975
- it('should throw NAuthException if user not found by phone', async () => {
976
- mockUserRepository.findOne.mockResolvedValue(null);
977
-
978
- try {
979
- await service.resendVerificationSMSForPhone('+9999999999');
980
- fail('Should have thrown NAuthException');
981
- } catch (error: any) {
982
- expect(error).toBeInstanceOf(NAuthException);
983
- expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
984
- }
985
- });
986
-
987
- it('should throw NAuthException if phone already verified', async () => {
988
- const verifiedUser = { ...mockUser, isPhoneVerified: true };
989
- mockUserRepository.findOne.mockResolvedValue(verifiedUser as any);
990
-
991
- try {
992
- await service.resendVerificationSMSForPhone('+1234567890');
993
- fail('Should have thrown NAuthException');
994
- } catch (error: any) {
995
- expect(error).toBeInstanceOf(NAuthException);
996
- expect(error.code).toBe(AuthErrorCode.ALREADY_VERIFIED);
997
- }
998
- });
999
-
1000
- it('should delegate to resendVerificationSMS with user sub', async () => {
1001
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
1002
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
1003
- mockStorageAdapter.incr.mockResolvedValue(1);
1004
- mockStorageAdapter.ttl.mockResolvedValue(3600);
1005
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
1006
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
1007
-
1008
- await service.resendVerificationSMSForPhone('+1234567890');
1009
-
1010
- // Should find user by phone, then use their sub to resend
1011
- expect(mockUserRepository.findOne).toHaveBeenCalledWith({ where: { phone: '+1234567890' } as any });
1012
- expect(mockSmsProvider.sendOTP).toHaveBeenCalled();
1013
- });
1014
- });
1015
-
1016
- // ============================================================================
1017
- // Service Without Optional Dependencies
1018
- // ============================================================================
1019
-
1020
- describe('Service without optional dependencies', () => {
1021
- it('should work without audit service', async () => {
1022
- const serviceWithoutAudit = new PhoneVerificationService(
1023
- mockVerificationTokenRepository,
1024
- mockUserRepository,
1025
- mockSmsProvider,
1026
- mockStorageAdapter,
1027
- mockConfig,
1028
- mockClientInfoService,
1029
- mockLogger,
1030
- undefined, // No audit service
1031
- );
1032
-
1033
- mockStorageAdapter.incr.mockResolvedValue(1);
1034
- mockStorageAdapter.ttl.mockResolvedValue(3600);
1035
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
1036
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
1037
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
1038
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
1039
-
1040
- await serviceWithoutAudit.sendVerificationSMS('user-sub-123');
1041
-
1042
- // Should not throw error
1043
- expect(mockSmsProvider.sendOTP).toHaveBeenCalled();
1044
- });
1045
- });
1046
-
1047
- // ============================================================================
1048
- // Edge Cases
1049
- // ============================================================================
1050
-
1051
- describe('Edge Cases', () => {
1052
- it('should handle TTL of -1 (key does not exist)', async () => {
1053
- mockStorageAdapter.ttl.mockResolvedValue(-1); // Key does not exist
1054
- mockStorageAdapter.incr.mockResolvedValue(1);
1055
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
1056
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
1057
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
1058
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
1059
-
1060
- await service.sendVerificationSMS('user-sub-123');
1061
-
1062
- // Should create new window
1063
- expect(mockStorageAdapter.incr).toHaveBeenCalled();
1064
- });
1065
-
1066
- it('should handle TTL of 0 (key expired)', async () => {
1067
- mockStorageAdapter.ttl.mockResolvedValue(0); // Key expired
1068
- mockStorageAdapter.incr.mockResolvedValue(1);
1069
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
1070
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
1071
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
1072
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
1073
-
1074
- await service.sendVerificationSMS('user-sub-123');
1075
-
1076
- // Should create new window (when TTL is 0, window is expired, so TTL parameter is passed)
1077
- expect(mockStorageAdapter.incr).toHaveBeenCalled();
1078
- });
1079
-
1080
- it('should handle negative TTL (key expired)', async () => {
1081
- mockStorageAdapter.ttl.mockResolvedValue(-10); // Negative TTL (expired)
1082
- mockStorageAdapter.incr.mockResolvedValue(1);
1083
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
1084
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
1085
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
1086
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
1087
-
1088
- await service.sendVerificationSMS('user-sub-123');
1089
-
1090
- // Should create new window
1091
- expect(mockStorageAdapter.incr).toHaveBeenCalled();
1092
- });
1093
-
1094
- it('should handle missing config phone verification settings', async () => {
1095
- mockConfig.phone = undefined;
1096
- service = new PhoneVerificationService(
1097
- mockVerificationTokenRepository,
1098
- mockUserRepository,
1099
- mockSmsProvider,
1100
- mockStorageAdapter,
1101
- mockConfig,
1102
- mockClientInfoService,
1103
- mockLogger,
1104
- mockAuditService,
1105
- );
1106
-
1107
- mockStorageAdapter.incr.mockResolvedValue(1);
1108
- mockStorageAdapter.ttl.mockResolvedValue(-1);
1109
- mockUserRepository.findOne.mockResolvedValue(mockUser as any);
1110
- mockVerificationTokenRepository.findOne.mockResolvedValue(null);
1111
- mockVerificationTokenRepository.create.mockReturnValue(mockVerificationToken as any);
1112
- mockVerificationTokenRepository.save.mockResolvedValue(mockVerificationToken as any);
1113
-
1114
- // Should use defaults (rateLimitMax: 3, rateLimitWindow: 3600, resendDelay: 60, expiresIn: 300, maxAttempts: 3)
1115
- await service.sendVerificationSMS('user-sub-123');
1116
-
1117
- expect(mockSmsProvider.sendOTP).toHaveBeenCalled();
1118
- });
1119
- });
1120
- });