@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,1293 +0,0 @@
1
- import { Repository, In } from 'typeorm';
2
- import { SessionService } from './session.service';
3
- import { ISession } from '../interfaces/entities.interface';
4
- import { BaseSession } from '../entities';
5
- import { StorageAdapter } from '../interfaces/storage-adapter.interface';
6
- import { NAuthLogger } from '../utils/nauth-logger';
7
- import { NAuthConfig } from '../interfaces/config.interface';
8
- import { AuthAuditService } from './auth-audit.service';
9
- import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
10
- import { ClientInfoService } from './client-info.service';
11
-
12
- /**
13
- * SessionService Unit Tests
14
- *
15
- * Platform-agnostic: Uses direct instantiation, no NestJS dependencies.
16
- *
17
- * Covers:
18
- * - Session creation with maxConcurrent limit
19
- * - User agent parsing and device detection
20
- * - Finding sessions (by ID, refresh token, user ID)
21
- * - Updating session activity and tokens (rotation)
22
- * - Atomic session creation
23
- * - Session revocation (single, all, token family) with audit logging
24
- * - Cleanup operations
25
- * - Session counting
26
- * - Token reuse detection
27
- * - Distributed locking
28
- */
29
- describe('SessionService', () => {
30
- let service: SessionService;
31
- let mockSessionRepository: jest.Mocked<Repository<BaseSession>>;
32
- let mockStorageAdapter: jest.Mocked<StorageAdapter>;
33
- let mockClientInfoService: jest.Mocked<ClientInfoService>;
34
- let mockConfig: NAuthConfig;
35
- let mockLogger: jest.Mocked<NAuthLogger>;
36
- let mockAuditService: jest.Mocked<AuthAuditService>;
37
-
38
- const mockSession: ISession = {
39
- id: 123,
40
- version: 1,
41
- userId: 123,
42
- accessTokenHash: 'access-hash-123',
43
- refreshTokenHash: 'refresh-hash-123',
44
- tokenFamily: 'family-abc',
45
- deviceId: 'device-123',
46
- deviceName: 'iPhone 13',
47
- deviceType: 'mobile',
48
- deviceFingerprint: 'fingerprint-123',
49
- ipAddress: '192.168.1.1',
50
- ipCountry: 'US',
51
- ipCity: 'New York',
52
- ipIsp: 'ISP Inc',
53
- userAgent: 'Mozilla/5.0...',
54
- platform: 'iOS',
55
- browser: 'Safari',
56
- authMethod: 'password',
57
- isRemembered: false,
58
- isTrustedDevice: false,
59
- expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
60
- lastActivityAt: new Date(),
61
- isRevoked: false,
62
- revokedAt: null,
63
- revokeReason: null,
64
- metadata: null,
65
- createdAt: new Date(),
66
- };
67
-
68
- beforeEach(() => {
69
- mockSessionRepository = {
70
- create: jest.fn(),
71
- save: jest.fn(),
72
- findOne: jest.fn(),
73
- find: jest.fn(),
74
- update: jest.fn(),
75
- delete: jest.fn(),
76
- count: jest.fn(),
77
- manager: {
78
- transaction: jest.fn(),
79
- } as any,
80
- } as any;
81
-
82
- mockStorageAdapter = {
83
- get: jest.fn(),
84
- set: jest.fn(),
85
- del: jest.fn(),
86
- exists: jest.fn(),
87
- incr: jest.fn(),
88
- decr: jest.fn(),
89
- expire: jest.fn(),
90
- ttl: jest.fn(),
91
- hget: jest.fn(),
92
- hset: jest.fn(),
93
- hgetall: jest.fn(),
94
- hdel: jest.fn(),
95
- lpush: jest.fn(),
96
- lrange: jest.fn(),
97
- llen: jest.fn(),
98
- keys: jest.fn(),
99
- scan: jest.fn(),
100
- initialize: jest.fn(),
101
- isHealthy: jest.fn().mockResolvedValue(true),
102
- cleanup: jest.fn(),
103
- disconnect: jest.fn(),
104
- } as any;
105
-
106
- mockLogger = {
107
- log: jest.fn(),
108
- error: jest.fn(),
109
- warn: jest.fn(),
110
- debug: jest.fn(),
111
- verbose: jest.fn(),
112
- } as any;
113
-
114
- mockAuditService = {
115
- recordEvent: jest.fn(),
116
- } as any;
117
-
118
- mockConfig = {
119
- jwt: {
120
- accessToken: { secret: 'test-secret', expiresIn: '15m' },
121
- refreshToken: { secret: 'test-refresh-secret', expiresIn: '7d' },
122
- },
123
- };
124
-
125
- mockClientInfoService = {
126
- get: jest.fn().mockReturnValue({
127
- ipAddress: '1.2.3.4',
128
- userAgent: 'test-agent',
129
- deviceToken: undefined,
130
- ipCountry: undefined,
131
- ipCity: undefined,
132
- platform: undefined,
133
- browser: undefined,
134
- }),
135
- } as any;
136
-
137
- // Instantiate service directly
138
- service = new SessionService(
139
- mockSessionRepository,
140
- mockStorageAdapter,
141
- mockClientInfoService,
142
- mockConfig,
143
- mockLogger,
144
- mockAuditService,
145
- );
146
- });
147
-
148
- afterEach(() => {
149
- jest.clearAllMocks();
150
- });
151
-
152
- // ============================================================================
153
- // Service Initialization
154
- // ============================================================================
155
-
156
- it('should be defined', () => {
157
- expect(service).toBeDefined();
158
- });
159
-
160
- // ============================================================================
161
- // Session Creation
162
- // ============================================================================
163
-
164
- describe('createSession', () => {
165
- it('should create a new session with all fields', async () => {
166
- // Set up client info mock to return test values
167
- mockClientInfoService.get.mockReturnValue({
168
- ipAddress: '192.168.1.1',
169
- ipCountry: 'US',
170
- ipCity: 'New York',
171
- userAgent: 'Mozilla/5.0...',
172
- platform: 'iOS',
173
- browser: 'Safari',
174
- deviceType: 'mobile',
175
- deviceName: 'Safari on iOS',
176
- });
177
-
178
- const sessionData = {
179
- userId: 123,
180
- accessTokenHash: 'access-hash',
181
- refreshTokenHash: 'refresh-hash',
182
- tokenFamily: 'family-abc',
183
- deviceId: 'device-123',
184
- deviceName: 'iPhone 13',
185
- deviceType: 'mobile',
186
- // Client info (ipAddress, ipCountry, ipCity, userAgent) automatically extracted from ClientInfoService
187
- expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
188
- isRemembered: true,
189
- authMethod: 'password',
190
- };
191
-
192
- mockSessionRepository.create.mockReturnValue(mockSession as any);
193
- mockSessionRepository.save.mockResolvedValue(mockSession as any);
194
-
195
- const result = await service.createSession(sessionData);
196
-
197
- expect(mockSessionRepository.create).toHaveBeenCalledWith(
198
- (expect as any).objectContaining({
199
- userId: sessionData.userId,
200
- accessTokenHash: sessionData.accessTokenHash,
201
- refreshTokenHash: sessionData.refreshTokenHash,
202
- tokenFamily: sessionData.tokenFamily,
203
- deviceId: sessionData.deviceId,
204
- deviceName: sessionData.deviceName,
205
- deviceType: sessionData.deviceType,
206
- // Client info comes from ClientInfoService mock
207
- ipAddress: '192.168.1.1',
208
- ipCountry: 'US',
209
- ipCity: 'New York',
210
- userAgent: 'Mozilla/5.0...',
211
- authMethod: sessionData.authMethod,
212
- isRemembered: true,
213
- }),
214
- );
215
- expect(mockSessionRepository.save).toHaveBeenCalled();
216
- expect(result).toEqual(mockSession);
217
- });
218
-
219
- it('should create session with minimal required fields', async () => {
220
- const sessionData = {
221
- userId: 123,
222
- accessTokenHash: 'access-hash',
223
- refreshTokenHash: 'refresh-hash',
224
- tokenFamily: 'family-abc',
225
- expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
226
- };
227
-
228
- mockSessionRepository.create.mockReturnValue(mockSession as any);
229
- mockSessionRepository.save.mockResolvedValue(mockSession as any);
230
-
231
- const result = await service.createSession(sessionData);
232
-
233
- expect(mockSessionRepository.create).toHaveBeenCalled();
234
- expect(mockSessionRepository.save).toHaveBeenCalled();
235
- expect(result).toEqual(mockSession);
236
- });
237
-
238
- it('should set isRemembered to false by default', async () => {
239
- const sessionData = {
240
- userId: 123,
241
- accessTokenHash: 'access-hash',
242
- refreshTokenHash: 'refresh-hash',
243
- tokenFamily: 'family-abc',
244
- expiresAt: new Date(),
245
- };
246
-
247
- mockSessionRepository.create.mockReturnValue(mockSession as any);
248
- mockSessionRepository.save.mockResolvedValue(mockSession as any);
249
-
250
- await service.createSession(sessionData);
251
-
252
- expect(mockSessionRepository.create).toHaveBeenCalledWith(
253
- (expect as any).objectContaining({
254
- isRemembered: false,
255
- }),
256
- );
257
- });
258
-
259
- it('should auto-generate deviceId if not provided', async () => {
260
- const sessionData = {
261
- userId: 123,
262
- accessTokenHash: 'access-hash',
263
- refreshTokenHash: 'refresh-hash',
264
- tokenFamily: 'family-abc',
265
- expiresAt: new Date(),
266
- };
267
-
268
- const createdSession = { ...mockSession, deviceId: 'auto-generated-uuid' };
269
- mockSessionRepository.create.mockReturnValue(createdSession as any);
270
- mockSessionRepository.save.mockResolvedValue(createdSession as any);
271
-
272
- await service.createSession(sessionData);
273
-
274
- // DeviceId should be generated (UUID format)
275
- const createCall = mockSessionRepository.create.mock.calls[0][0];
276
- expect(createCall.deviceId).toBeDefined();
277
- expect(typeof createCall.deviceId).toBe('string');
278
- if (createCall.deviceId) {
279
- expect(createCall.deviceId.length).toBeGreaterThan(0);
280
- }
281
- });
282
-
283
- it('should parse user agent for device information', async () => {
284
- const sessionData = {
285
- userId: 123,
286
- accessTokenHash: 'access-hash',
287
- refreshTokenHash: 'refresh-hash',
288
- tokenFamily: 'family-abc',
289
- userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15',
290
- expiresAt: new Date(),
291
- };
292
-
293
- mockSessionRepository.create.mockReturnValue(mockSession as any);
294
- mockSessionRepository.save.mockResolvedValue(mockSession as any);
295
-
296
- await service.createSession(sessionData);
297
-
298
- // User agent parsing should be attempted
299
- expect(mockSessionRepository.create).toHaveBeenCalled();
300
- });
301
-
302
- it('should enforce maxConcurrent session limit', async () => {
303
- mockConfig.session = { maxConcurrent: 2 };
304
- service = new SessionService(
305
- mockSessionRepository,
306
- mockStorageAdapter,
307
- mockClientInfoService,
308
- mockConfig,
309
- mockLogger,
310
- mockAuditService,
311
- );
312
-
313
- // Mock 3 active sessions (exceeds limit of 2)
314
- const activeSessions = [{ id: 1 }, { id: 2 }, { id: 3 }];
315
- mockSessionRepository.find.mockResolvedValueOnce(activeSessions as any);
316
- mockSessionRepository.update.mockResolvedValueOnce({ affected: 2 } as any);
317
- mockSessionRepository.create.mockReturnValue(mockSession as any);
318
- mockSessionRepository.save.mockResolvedValue(mockSession as any);
319
-
320
- const sessionData = {
321
- userId: 123,
322
- accessTokenHash: 'access-hash',
323
- refreshTokenHash: 'refresh-hash',
324
- tokenFamily: 'family-abc',
325
- expiresAt: new Date(),
326
- };
327
-
328
- await service.createSession(sessionData);
329
-
330
- // Should revoke oldest 2 sessions (3 - 2 + 1 = 2)
331
- expect(mockSessionRepository.update).toHaveBeenCalledWith(
332
- { id: In([1, 2]) } as any,
333
- (expect as any).objectContaining({
334
- isRevoked: true,
335
- revokeReason: 'Max concurrent sessions exceeded',
336
- }),
337
- );
338
- });
339
-
340
- it('should not revoke sessions if under maxConcurrent limit', async () => {
341
- mockConfig.session = { maxConcurrent: 5 };
342
- service = new SessionService(
343
- mockSessionRepository,
344
- mockStorageAdapter,
345
- mockClientInfoService,
346
- mockConfig,
347
- mockLogger,
348
- mockAuditService,
349
- );
350
-
351
- // Mock 2 active sessions (under limit of 5)
352
- mockSessionRepository.find.mockResolvedValueOnce([{ id: 1 }, { id: 2 }] as any);
353
- mockSessionRepository.create.mockReturnValue(mockSession as any);
354
- mockSessionRepository.save.mockResolvedValue(mockSession as any);
355
-
356
- const sessionData = {
357
- userId: 123,
358
- accessTokenHash: 'access-hash',
359
- refreshTokenHash: 'refresh-hash',
360
- tokenFamily: 'family-abc',
361
- expiresAt: new Date(),
362
- };
363
-
364
- await service.createSession(sessionData);
365
-
366
- // Should not call update for revocation
367
- expect(mockSessionRepository.update).not.toHaveBeenCalled();
368
- });
369
-
370
- it('should audit log when sessions are revoked due to maxConcurrent', async () => {
371
- mockConfig.session = { maxConcurrent: 1 };
372
- service = new SessionService(
373
- mockSessionRepository,
374
- mockStorageAdapter,
375
- mockClientInfoService,
376
- mockConfig,
377
- mockLogger,
378
- mockAuditService,
379
- );
380
-
381
- mockSessionRepository.find.mockResolvedValueOnce([{ id: 1 }, { id: 2 }] as any);
382
- mockSessionRepository.update.mockResolvedValueOnce({ affected: 2 } as any);
383
- mockSessionRepository.create.mockReturnValue(mockSession as any);
384
- mockSessionRepository.save.mockResolvedValue(mockSession as any);
385
- mockAuditService.recordEvent.mockResolvedValue(null);
386
-
387
- const sessionData = {
388
- userId: 123,
389
- accessTokenHash: 'access-hash',
390
- refreshTokenHash: 'refresh-hash',
391
- tokenFamily: 'family-abc',
392
- expiresAt: new Date(),
393
- };
394
-
395
- await service.createSession(sessionData);
396
-
397
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
398
- (expect as any).objectContaining({
399
- userId: 123,
400
- eventType: AuthAuditEventType.SESSION_REVOKED,
401
- eventStatus: 'INFO',
402
- reason: 'Max concurrent sessions exceeded',
403
- }),
404
- );
405
- });
406
-
407
- it('should handle audit logging errors gracefully', async () => {
408
- mockConfig.session = { maxConcurrent: 1 };
409
- service = new SessionService(
410
- mockSessionRepository,
411
- mockStorageAdapter,
412
- mockClientInfoService,
413
- mockConfig,
414
- mockLogger,
415
- mockAuditService,
416
- );
417
-
418
- mockSessionRepository.find.mockResolvedValueOnce([{ id: 1 }] as any);
419
- mockSessionRepository.update.mockResolvedValueOnce({ affected: 1 } as any);
420
- mockSessionRepository.create.mockReturnValue(mockSession as any);
421
- mockSessionRepository.save.mockResolvedValue(mockSession as any);
422
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit service error'));
423
-
424
- const sessionData = {
425
- userId: 123,
426
- accessTokenHash: 'access-hash',
427
- refreshTokenHash: 'refresh-hash',
428
- tokenFamily: 'family-abc',
429
- expiresAt: new Date(),
430
- };
431
-
432
- await service.createSession(sessionData);
433
-
434
- expect(mockLogger.error).toHaveBeenCalled();
435
- // Session should still be created despite audit error
436
- expect(mockSessionRepository.save).toHaveBeenCalled();
437
- });
438
-
439
- it('should handle missing affected property in update result', async () => {
440
- mockConfig.session = { maxConcurrent: 1 };
441
- service = new SessionService(
442
- mockSessionRepository,
443
- mockStorageAdapter,
444
- mockClientInfoService,
445
- mockConfig,
446
- mockLogger,
447
- mockAuditService,
448
- );
449
-
450
- mockSessionRepository.find.mockResolvedValueOnce([{ id: 1 }] as any);
451
- mockSessionRepository.update.mockResolvedValueOnce({} as any); // No affected property
452
- mockSessionRepository.create.mockReturnValue(mockSession as any);
453
- mockSessionRepository.save.mockResolvedValue(mockSession as any);
454
-
455
- const sessionData = {
456
- userId: 123,
457
- accessTokenHash: 'access-hash',
458
- refreshTokenHash: 'refresh-hash',
459
- tokenFamily: 'family-abc',
460
- expiresAt: new Date(),
461
- };
462
-
463
- await service.createSession(sessionData);
464
-
465
- // Should handle gracefully without throwing
466
- expect(mockSessionRepository.save).toHaveBeenCalled();
467
- });
468
- });
469
-
470
- // ============================================================================
471
- // Finding Sessions
472
- // ============================================================================
473
-
474
- describe('findById', () => {
475
- it('should find session by numeric ID', async () => {
476
- mockSessionRepository.findOne.mockResolvedValue(mockSession as any);
477
-
478
- const result = await service.findById(123);
479
-
480
- expect(mockSessionRepository.findOne).toHaveBeenCalledWith({
481
- where: { id: 123 },
482
- });
483
- expect(result).toEqual(mockSession);
484
- });
485
-
486
- it('should find session by string ID', async () => {
487
- mockSessionRepository.findOne.mockResolvedValue(mockSession as any);
488
-
489
- const result = await service.findById('123');
490
-
491
- expect(mockSessionRepository.findOne).toHaveBeenCalledWith({
492
- where: { id: 123 },
493
- });
494
- expect(result).toEqual(mockSession);
495
- });
496
-
497
- it('should return null if session not found', async () => {
498
- mockSessionRepository.findOne.mockResolvedValue(null);
499
-
500
- const result = await service.findById(999);
501
-
502
- expect(result).toBeNull();
503
- });
504
- });
505
-
506
- describe('findByIdLight', () => {
507
- it('should find session with minimal fields', async () => {
508
- const lightSession = {
509
- id: 123,
510
- version: 1,
511
- isRevoked: false,
512
- expiresAt: new Date(),
513
- userId: 123,
514
- };
515
- mockSessionRepository.findOne.mockResolvedValue(lightSession as any);
516
-
517
- const result = await service.findByIdLight(123);
518
-
519
- expect(mockSessionRepository.findOne).toHaveBeenCalledWith({
520
- select: ['id', 'version', 'isRevoked', 'expiresAt', 'userId'],
521
- where: { id: 123 },
522
- });
523
- expect(result).toEqual(lightSession);
524
- });
525
-
526
- it('should return null if session not found', async () => {
527
- mockSessionRepository.findOne.mockResolvedValue(null);
528
-
529
- const result = await service.findByIdLight(999);
530
-
531
- expect(result).toBeNull();
532
- });
533
-
534
- it('should handle string ID', async () => {
535
- const lightSession = {
536
- id: 123,
537
- version: 1,
538
- isRevoked: false,
539
- expiresAt: new Date(),
540
- userId: 123,
541
- };
542
- mockSessionRepository.findOne.mockResolvedValue(lightSession as any);
543
-
544
- const result = await service.findByIdLight('123');
545
-
546
- expect(mockSessionRepository.findOne).toHaveBeenCalledWith({
547
- select: ['id', 'version', 'isRevoked', 'expiresAt', 'userId'],
548
- where: { id: 123 },
549
- });
550
- expect(result).toEqual(lightSession);
551
- });
552
-
553
- it('should handle missing version field', async () => {
554
- const lightSessionWithoutVersion = {
555
- id: 123,
556
- isRevoked: false,
557
- expiresAt: new Date(),
558
- userId: 123,
559
- };
560
- mockSessionRepository.findOne.mockResolvedValue(lightSessionWithoutVersion as any);
561
-
562
- const result = await service.findByIdLight(123);
563
-
564
- expect(result).toBeDefined();
565
- });
566
- });
567
-
568
- describe('findByRefreshToken', () => {
569
- it('should find session by refresh token hash', async () => {
570
- mockSessionRepository.findOne.mockResolvedValue(mockSession as any);
571
-
572
- const result = await service.findByRefreshToken('refresh-hash-123');
573
-
574
- expect(mockSessionRepository.findOne).toHaveBeenCalledWith({
575
- select: ['id', 'userId', 'isRevoked', 'tokenFamily', 'expiresAt'],
576
- where: { refreshTokenHash: 'refresh-hash-123', isRevoked: false },
577
- });
578
- expect(result).toEqual(mockSession);
579
- });
580
-
581
- it('should return null if session not found', async () => {
582
- mockSessionRepository.findOne.mockResolvedValue(null);
583
-
584
- const result = await service.findByRefreshToken('invalid-hash');
585
-
586
- expect(result).toBeNull();
587
- });
588
- });
589
-
590
- describe('findUserSessions', () => {
591
- it('should find all active sessions for a user', async () => {
592
- const sessions = [mockSession, { ...mockSession, id: 456 }];
593
- mockSessionRepository.find.mockResolvedValue(sessions as any);
594
-
595
- const result = await service.findUserSessions(123);
596
-
597
- expect(mockSessionRepository.find).toHaveBeenCalledWith({
598
- where: { userId: 123, isRevoked: false },
599
- order: { createdAt: 'DESC' },
600
- });
601
- expect(result).toEqual(sessions);
602
- });
603
-
604
- it('should return empty array if no sessions found', async () => {
605
- mockSessionRepository.find.mockResolvedValue([]);
606
-
607
- const result = await service.findUserSessions(999);
608
-
609
- expect(result).toEqual([]);
610
- });
611
- });
612
-
613
- // ============================================================================
614
- // Updating Sessions
615
- // ============================================================================
616
-
617
- describe('updateActivity', () => {
618
- it('should update session activity timestamp with numeric ID', async () => {
619
- mockSessionRepository.update.mockResolvedValue({ affected: 1 } as any);
620
-
621
- await service.updateActivity(123);
622
-
623
- expect(mockSessionRepository.update).toHaveBeenCalledWith(
624
- 123,
625
- (expect as any).objectContaining({
626
- lastActivityAt: (expect as any).any(Date),
627
- }),
628
- );
629
- });
630
-
631
- it('should update session activity timestamp with string ID', async () => {
632
- mockSessionRepository.update.mockResolvedValue({ affected: 1 } as any);
633
-
634
- await service.updateActivity('123');
635
-
636
- expect(mockSessionRepository.update).toHaveBeenCalledWith(
637
- 123,
638
- (expect as any).objectContaining({
639
- lastActivityAt: (expect as any).any(Date),
640
- }),
641
- );
642
- });
643
- });
644
-
645
- describe('updateTokens', () => {
646
- it('should update session with new token hashes', async () => {
647
- mockSessionRepository.update.mockResolvedValue({ affected: 1 } as any);
648
-
649
- await service.updateTokens(123, 'new-access-hash', 'new-refresh-hash');
650
-
651
- expect(mockSessionRepository.update).toHaveBeenCalledWith(123, {
652
- accessTokenHash: 'new-access-hash',
653
- refreshTokenHash: 'new-refresh-hash',
654
- lastActivityAt: (expect as any).any(Date),
655
- });
656
- });
657
-
658
- it('should handle string session ID', async () => {
659
- mockSessionRepository.update.mockResolvedValue({ affected: 1 } as any);
660
-
661
- await service.updateTokens('123', 'new-access-hash', 'new-refresh-hash');
662
-
663
- expect(mockSessionRepository.update).toHaveBeenCalledWith(
664
- 123,
665
- (expect as any).objectContaining({
666
- accessTokenHash: 'new-access-hash',
667
- }),
668
- );
669
- });
670
- });
671
-
672
- // ============================================================================
673
- // Atomic Session Creation
674
- // ============================================================================
675
-
676
- describe('createSessionAtomic', () => {
677
- it('should create session atomically with hash generation', async () => {
678
- const sessionData = {
679
- userId: 123,
680
- tokenFamily: 'family-abc',
681
- expiresAt: new Date(),
682
- };
683
-
684
- const mockTransaction = jest.fn(async (callback) => {
685
- const mockTrx = {
686
- save: jest.fn().mockResolvedValue({ id: 123 } as any),
687
- createQueryBuilder: jest.fn().mockReturnValue({
688
- update: jest.fn().mockReturnValue({
689
- set: jest.fn().mockReturnValue({
690
- where: jest.fn().mockReturnValue({
691
- execute: jest.fn().mockResolvedValue(undefined),
692
- }),
693
- }),
694
- }),
695
- }),
696
- findOne: jest.fn().mockResolvedValue(mockSession as any),
697
- };
698
- return await callback(mockTrx);
699
- });
700
-
701
- mockSessionRepository.manager.transaction = mockTransaction as any;
702
-
703
- const generateHashes = jest.fn().mockResolvedValue({
704
- accessTokenHash: 'access-hash',
705
- refreshTokenHash: 'refresh-hash',
706
- });
707
-
708
- const result = await service.createSessionAtomic(sessionData, generateHashes);
709
-
710
- expect(mockTransaction).toHaveBeenCalled();
711
- expect(generateHashes).toHaveBeenCalledWith(123);
712
- expect(result.session).toBeDefined();
713
- });
714
-
715
- it('should handle extra data from hash generation', async () => {
716
- const sessionData = {
717
- userId: 123,
718
- tokenFamily: 'family-abc',
719
- expiresAt: new Date(),
720
- };
721
-
722
- const mockTransaction = jest.fn(async (callback) => {
723
- const mockTrx = {
724
- save: jest.fn().mockResolvedValue({ id: 123 } as any),
725
- createQueryBuilder: jest.fn().mockReturnValue({
726
- update: jest.fn().mockReturnValue({
727
- set: jest.fn().mockReturnValue({
728
- where: jest.fn().mockReturnValue({
729
- execute: jest.fn().mockResolvedValue(undefined),
730
- }),
731
- }),
732
- }),
733
- }),
734
- findOne: jest.fn().mockResolvedValue(mockSession as any),
735
- };
736
- return await callback(mockTrx);
737
- });
738
-
739
- mockSessionRepository.manager.transaction = mockTransaction as any;
740
-
741
- const generateHashes = jest.fn().mockResolvedValue({
742
- accessTokenHash: 'access-hash',
743
- refreshTokenHash: 'refresh-hash',
744
- extra: { customField: 'value' },
745
- });
746
-
747
- const result = await service.createSessionAtomic(sessionData, generateHashes);
748
-
749
- expect(result.extra).toEqual({ customField: 'value' });
750
- });
751
-
752
- it('should throw error if session not found after creation', async () => {
753
- const sessionData = {
754
- userId: 123,
755
- tokenFamily: 'family-abc',
756
- expiresAt: new Date(),
757
- };
758
-
759
- const mockTransaction = jest.fn(async (callback) => {
760
- const mockTrx = {
761
- save: jest.fn().mockResolvedValue({ id: 123 } as any),
762
- createQueryBuilder: jest.fn().mockReturnValue({
763
- update: jest.fn().mockReturnValue({
764
- set: jest.fn().mockReturnValue({
765
- where: jest.fn().mockReturnValue({
766
- execute: jest.fn().mockResolvedValue(undefined),
767
- }),
768
- }),
769
- }),
770
- }),
771
- findOne: jest.fn().mockResolvedValue(null), // Session not found
772
- };
773
- return await callback(mockTrx);
774
- });
775
-
776
- mockSessionRepository.manager.transaction = mockTransaction as any;
777
-
778
- const generateHashes = jest.fn().mockResolvedValue({
779
- accessTokenHash: 'access-hash',
780
- refreshTokenHash: 'refresh-hash',
781
- });
782
-
783
- try {
784
- await service.createSessionAtomic(sessionData, generateHashes);
785
- fail('Expected error to be thrown');
786
- } catch (error) {
787
- expect(error).toBeInstanceOf(Error);
788
- expect((error as Error).message).toBe('Failed to load session after creation');
789
- }
790
- });
791
- });
792
-
793
- // ============================================================================
794
- // Session Revocation
795
- // ============================================================================
796
-
797
- describe('revokeSession', () => {
798
- it('should revoke a single session with reason', async () => {
799
- mockSessionRepository.findOne.mockResolvedValue(mockSession as any);
800
- mockSessionRepository.update.mockResolvedValue({ affected: 1 } as any);
801
- mockAuditService.recordEvent.mockResolvedValue(null);
802
-
803
- await service.revokeSession(123, 'User logout');
804
-
805
- expect(mockSessionRepository.findOne).toHaveBeenCalledWith({
806
- where: { id: 123 },
807
- });
808
- expect(mockSessionRepository.update).toHaveBeenCalledWith(123, {
809
- isRevoked: true,
810
- revokedAt: (expect as any).any(Date),
811
- revokeReason: 'User logout',
812
- });
813
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
814
- (expect as any).objectContaining({
815
- userId: 123,
816
- eventType: AuthAuditEventType.SESSION_REVOKED,
817
- sessionId: 123,
818
- reason: 'User logout',
819
- }),
820
- );
821
- });
822
-
823
- it('should revoke session without reason', async () => {
824
- mockSessionRepository.findOne.mockResolvedValue(mockSession as any);
825
- mockSessionRepository.update.mockResolvedValue({ affected: 1 } as any);
826
- mockAuditService.recordEvent.mockResolvedValue(null);
827
-
828
- await service.revokeSession(123);
829
-
830
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
831
- (expect as any).objectContaining({
832
- reason: 'User logout',
833
- }),
834
- );
835
- });
836
-
837
- it('should handle string session ID', async () => {
838
- mockSessionRepository.findOne.mockResolvedValue(mockSession as any);
839
- mockSessionRepository.update.mockResolvedValue({ affected: 1 } as any);
840
- mockAuditService.recordEvent.mockResolvedValue(null);
841
-
842
- await service.revokeSession('123', 'User logout');
843
-
844
- expect(mockSessionRepository.findOne).toHaveBeenCalledWith({
845
- where: { id: 123 },
846
- });
847
- });
848
-
849
- it('should return early if session not found', async () => {
850
- mockSessionRepository.findOne.mockResolvedValue(null);
851
-
852
- await service.revokeSession(999);
853
-
854
- expect(mockSessionRepository.update).not.toHaveBeenCalled();
855
- expect(mockAuditService.recordEvent).not.toHaveBeenCalled();
856
- });
857
-
858
- it('should handle audit logging errors gracefully', async () => {
859
- mockSessionRepository.findOne.mockResolvedValue(mockSession as any);
860
- mockSessionRepository.update.mockResolvedValue({ affected: 1 } as any);
861
- mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
862
-
863
- await service.revokeSession(123, 'User logout');
864
-
865
- expect(mockLogger.error).toHaveBeenCalled();
866
- // Session should still be revoked despite audit error
867
- expect(mockSessionRepository.update).toHaveBeenCalled();
868
- });
869
-
870
- it('should include metadata in audit log', async () => {
871
- mockSessionRepository.findOne.mockResolvedValue(mockSession as any);
872
- mockSessionRepository.update.mockResolvedValue({ affected: 1 } as any);
873
- mockAuditService.recordEvent.mockResolvedValue(null);
874
-
875
- const metadata = { customField: 'value' };
876
- await service.revokeSession(123, 'User logout', metadata);
877
-
878
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
879
- (expect as any).objectContaining({
880
- metadata,
881
- }),
882
- );
883
- });
884
- });
885
-
886
- describe('revokeAllUserSessions', () => {
887
- it('should revoke all user sessions (global signout)', async () => {
888
- const sessions = [
889
- { ...mockSession, id: 123 },
890
- { ...mockSession, id: 456 },
891
- { ...mockSession, id: 789 },
892
- ];
893
- mockSessionRepository.find.mockResolvedValue(sessions as any);
894
- mockSessionRepository.update.mockResolvedValue({ affected: 3 } as any);
895
- mockAuditService.recordEvent.mockResolvedValue(null);
896
-
897
- const count = await service.revokeAllUserSessions(123, 'Global signout');
898
-
899
- expect(mockSessionRepository.find).toHaveBeenCalledWith({
900
- where: { userId: 123, isRevoked: false },
901
- order: { createdAt: 'DESC' },
902
- });
903
- expect(mockSessionRepository.update).toHaveBeenCalledWith(
904
- { userId: 123, isRevoked: false },
905
- {
906
- isRevoked: true,
907
- revokedAt: (expect as any).any(Date),
908
- revokeReason: 'Global signout',
909
- },
910
- );
911
- expect(count).toBe(3);
912
- // For global signout, should record individual SESSION_REVOKED event for each session
913
- expect(mockAuditService.recordEvent).toHaveBeenCalledTimes(3);
914
- // Should record individual SESSION_REVOKED event for each session
915
- expect(mockAuditService.recordEvent).toHaveBeenCalledTimes(3);
916
- sessions.forEach((session) => {
917
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
918
- (expect as any).objectContaining({
919
- userId: 123,
920
- eventType: AuthAuditEventType.SESSION_REVOKED,
921
- reason: 'Global signout',
922
- description: 'Session revoked by global signout',
923
- sessionId: session.id,
924
- }),
925
- );
926
- });
927
- });
928
-
929
- it('should return 0 if no sessions to revoke', async () => {
930
- mockSessionRepository.find.mockResolvedValue([]);
931
- mockSessionRepository.update.mockResolvedValue({ affected: 0 } as any);
932
-
933
- const count = await service.revokeAllUserSessions(999);
934
-
935
- expect(count).toBe(0);
936
- expect(mockAuditService.recordEvent).not.toHaveBeenCalled();
937
- });
938
-
939
- it('should use default reason if not provided', async () => {
940
- mockSessionRepository.find.mockResolvedValue([mockSession] as any);
941
- mockSessionRepository.update.mockResolvedValue({ affected: 1 } as any);
942
- mockAuditService.recordEvent.mockResolvedValue(null);
943
-
944
- await service.revokeAllUserSessions(123);
945
-
946
- // When reason is not "Global signout", should record one summary event
947
- expect(mockAuditService.recordEvent).toHaveBeenCalledTimes(1);
948
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
949
- (expect as any).objectContaining({
950
- eventType: AuthAuditEventType.SESSION_REVOKED,
951
- reason: 'Global signout',
952
- description: 'All user sessions revoked (1 session(s))',
953
- }),
954
- );
955
- });
956
-
957
- it('should handle audit logging errors gracefully', async () => {
958
- mockSessionRepository.find.mockResolvedValue([mockSession] as any);
959
- mockSessionRepository.update.mockResolvedValue({ affected: 1 } as any);
960
- (mockAuditService.recordEvent as jest.Mock).mockRejectedValue(new Error('Audit error'));
961
-
962
- const count = await service.revokeAllUserSessions(123);
963
-
964
- expect(count).toBe(1);
965
- expect(mockLogger.error).toHaveBeenCalled();
966
- });
967
-
968
- it('should include session IDs in audit metadata (non-global signout)', async () => {
969
- const sessions = [
970
- { ...mockSession, id: 1 },
971
- { ...mockSession, id: 2 },
972
- ];
973
- mockSessionRepository.find.mockResolvedValue(sessions as any);
974
- mockSessionRepository.update.mockResolvedValue({ affected: 2 } as any);
975
- (mockAuditService.recordEvent as jest.Mock).mockResolvedValue(null);
976
-
977
- await service.revokeAllUserSessions(123, 'Login from new session');
978
-
979
- // For non-global signout, should record one summary event
980
- expect(mockAuditService.recordEvent).toHaveBeenCalledTimes(1);
981
- expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
982
- (expect as any).objectContaining({
983
- metadata: {
984
- revokedCount: 2,
985
- sessionIds: [1, 2],
986
- },
987
- }),
988
- );
989
- });
990
- });
991
-
992
- describe('revokeTokenFamily', () => {
993
- it('should revoke all sessions in token family (reuse detection)', async () => {
994
- mockSessionRepository.update.mockResolvedValue({ affected: 2 } as any);
995
-
996
- const count = await service.revokeTokenFamily('family-abc', 'Token reuse detected');
997
-
998
- expect(mockSessionRepository.update).toHaveBeenCalledWith(
999
- { tokenFamily: 'family-abc', isRevoked: false },
1000
- {
1001
- isRevoked: true,
1002
- revokedAt: (expect as any).any(Date),
1003
- revokeReason: 'Token reuse detected',
1004
- },
1005
- );
1006
- expect(count).toBe(2);
1007
- });
1008
-
1009
- it('should use default reason if not provided', async () => {
1010
- mockSessionRepository.update.mockResolvedValue({ affected: 1 } as any);
1011
-
1012
- await service.revokeTokenFamily('family-xyz');
1013
-
1014
- expect(mockSessionRepository.update).toHaveBeenCalledWith(
1015
- (expect as any).any(Object),
1016
- (expect as any).objectContaining({
1017
- revokeReason: 'Token reuse detected',
1018
- }),
1019
- );
1020
- });
1021
-
1022
- it('should return 0 if no sessions in family', async () => {
1023
- mockSessionRepository.update.mockResolvedValue({ affected: 0 } as any);
1024
-
1025
- const count = await service.revokeTokenFamily('nonexistent-family');
1026
-
1027
- expect(count).toBe(0);
1028
- });
1029
-
1030
- it('should handle missing affected property', async () => {
1031
- mockSessionRepository.update.mockResolvedValue({} as any);
1032
-
1033
- const count = await service.revokeTokenFamily('family-abc');
1034
-
1035
- expect(count).toBe(0);
1036
- });
1037
- });
1038
-
1039
- // ============================================================================
1040
- // Cleanup Operations
1041
- // ============================================================================
1042
-
1043
- describe('cleanupExpiredSessions', () => {
1044
- it('should delete expired sessions', async () => {
1045
- mockSessionRepository.delete.mockResolvedValue({ affected: 5 } as any);
1046
-
1047
- const count = await service.cleanupExpiredSessions();
1048
-
1049
- expect(mockSessionRepository.delete).toHaveBeenCalledWith({
1050
- expiresAt: (expect as any).any(Object), // LessThan matcher
1051
- });
1052
- expect(count).toBe(5);
1053
- });
1054
-
1055
- it('should return 0 if no expired sessions', async () => {
1056
- mockSessionRepository.delete.mockResolvedValue({ affected: 0 } as any);
1057
-
1058
- const count = await service.cleanupExpiredSessions();
1059
-
1060
- expect(count).toBe(0);
1061
- });
1062
-
1063
- it('should handle missing affected property', async () => {
1064
- mockSessionRepository.delete.mockResolvedValue({} as any);
1065
-
1066
- const count = await service.cleanupExpiredSessions();
1067
-
1068
- expect(count).toBe(0);
1069
- });
1070
- });
1071
-
1072
- // ============================================================================
1073
- // Session Counting
1074
- // ============================================================================
1075
-
1076
- describe('countUserSessions', () => {
1077
- it('should count active sessions for a user', async () => {
1078
- mockSessionRepository.count.mockResolvedValue(3);
1079
-
1080
- const count = await service.countUserSessions(123);
1081
-
1082
- expect(mockSessionRepository.count).toHaveBeenCalledWith({
1083
- where: { userId: 123, isRevoked: false },
1084
- });
1085
- expect(count).toBe(3);
1086
- });
1087
-
1088
- it('should return 0 for user with no sessions', async () => {
1089
- mockSessionRepository.count.mockResolvedValue(0);
1090
-
1091
- const count = await service.countUserSessions(999);
1092
-
1093
- expect(count).toBe(0);
1094
- });
1095
- });
1096
-
1097
- // ============================================================================
1098
- // Token Reuse Detection
1099
- // ============================================================================
1100
-
1101
- describe('markRefreshTokenAsUsed', () => {
1102
- it('should mark token as used in storage with TTL', async () => {
1103
- const tokenHash = 'abc123hash';
1104
- const ttlSeconds = 2592000; // 30 days
1105
-
1106
- mockStorageAdapter.set.mockResolvedValue('true');
1107
-
1108
- const result = await service.markRefreshTokenAsUsed(tokenHash, ttlSeconds);
1109
-
1110
- expect(mockStorageAdapter.set).toHaveBeenCalledWith(`used-token:${tokenHash}`, 'true', ttlSeconds, { nx: true });
1111
- expect(result).toBe(true);
1112
- });
1113
-
1114
- it('should return false if token already marked as used', async () => {
1115
- const tokenHash = 'abc123hash';
1116
-
1117
- // NX set returns null if key already exists
1118
- mockStorageAdapter.set.mockResolvedValue(null);
1119
-
1120
- const result = await service.markRefreshTokenAsUsed(tokenHash, 3600);
1121
-
1122
- expect(result).toBe(false);
1123
- });
1124
-
1125
- it('should use correct key format', async () => {
1126
- const tokenHash = 'xyz789';
1127
- mockStorageAdapter.set.mockResolvedValue('true');
1128
-
1129
- await service.markRefreshTokenAsUsed(tokenHash, 3600);
1130
-
1131
- const call = mockStorageAdapter.set.mock.calls[0];
1132
- expect(call[0]).toBe('used-token:xyz789');
1133
- });
1134
- });
1135
-
1136
- describe('isRefreshTokenUsed', () => {
1137
- it('should return true if token already used', async () => {
1138
- const tokenHash = 'abc123hash';
1139
- mockStorageAdapter.exists.mockResolvedValue(true);
1140
-
1141
- const result = await service.isRefreshTokenUsed(tokenHash);
1142
-
1143
- expect(result).toBe(true);
1144
- expect(mockStorageAdapter.exists).toHaveBeenCalledWith(`used-token:${tokenHash}`);
1145
- });
1146
-
1147
- it('should return false if token not used', async () => {
1148
- const tokenHash = 'abc123hash';
1149
- mockStorageAdapter.exists.mockResolvedValue(false);
1150
-
1151
- const result = await service.isRefreshTokenUsed(tokenHash);
1152
-
1153
- expect(result).toBe(false);
1154
- });
1155
-
1156
- it('should check correct key format', async () => {
1157
- const tokenHash = 'unique-token-hash';
1158
- mockStorageAdapter.exists.mockResolvedValue(false);
1159
-
1160
- await service.isRefreshTokenUsed(tokenHash);
1161
-
1162
- expect(mockStorageAdapter.exists).toHaveBeenCalledWith('used-token:unique-token-hash');
1163
- });
1164
- });
1165
-
1166
- // ============================================================================
1167
- // Distributed Locking
1168
- // ============================================================================
1169
-
1170
- describe('acquireRefreshLock', () => {
1171
- it('should acquire lock if not exists', async () => {
1172
- const lockKey = 'session-refresh:123';
1173
- const ttlMs = 5000;
1174
-
1175
- // NX set returns non-null if lock acquired
1176
- mockStorageAdapter.set.mockResolvedValue('locked');
1177
-
1178
- const acquired = await service.acquireRefreshLock(lockKey, ttlMs);
1179
-
1180
- expect(acquired).toBe(true);
1181
- expect(mockStorageAdapter.set).toHaveBeenCalledWith(
1182
- lockKey,
1183
- 'locked',
1184
- (expect as any).any(Number), // TTL with jitter
1185
- { nx: true },
1186
- );
1187
- // TTL should be converted from ms to seconds (5 seconds)
1188
- const ttlCall = mockStorageAdapter.set.mock.calls[0][2];
1189
- expect(ttlCall).toBeGreaterThanOrEqual(4); // Allow for jitter
1190
- expect(ttlCall).toBeLessThanOrEqual(6);
1191
- });
1192
-
1193
- it('should fail to acquire lock if already exists', async () => {
1194
- const lockKey = 'session-refresh:123';
1195
-
1196
- // NX set returns null if lock already exists
1197
- mockStorageAdapter.set.mockResolvedValue(null);
1198
-
1199
- const acquired = await service.acquireRefreshLock(lockKey, 5000);
1200
-
1201
- expect(acquired).toBe(false);
1202
- });
1203
-
1204
- it('should use default TTL of 10 seconds', async () => {
1205
- const lockKey = 'session-refresh:123';
1206
- mockStorageAdapter.set.mockResolvedValue('locked');
1207
-
1208
- await service.acquireRefreshLock(lockKey); // No TTL provided
1209
-
1210
- const ttlCall = mockStorageAdapter.set.mock.calls[0][2];
1211
- // Default is 10000ms = 10 seconds, with jitter
1212
- expect(ttlCall).toBeGreaterThanOrEqual(9);
1213
- expect(ttlCall).toBeLessThanOrEqual(11);
1214
- });
1215
-
1216
- it('should add jitter to TTL', async () => {
1217
- const lockKey = 'session-refresh:123';
1218
- const ttlMs = 10000; // 10 seconds
1219
-
1220
- mockStorageAdapter.set.mockResolvedValue('locked');
1221
-
1222
- // Call multiple times to check jitter variation
1223
- const ttls: number[] = [];
1224
- for (let i = 0; i < 10; i++) {
1225
- await service.acquireRefreshLock(lockKey, ttlMs);
1226
- const ttl = mockStorageAdapter.set.mock.calls[i][2];
1227
- if (ttl !== undefined) {
1228
- ttls.push(ttl);
1229
- }
1230
- }
1231
-
1232
- // Should have some variation (jitter)
1233
- const uniqueTtls = new Set(ttls);
1234
- // Allow some variation but within expected range
1235
- expect(ttls.every((ttl) => ttl >= 9 && ttl <= 11)).toBe(true);
1236
- });
1237
-
1238
- it('should handle minimum TTL of 1 second', async () => {
1239
- const lockKey = 'session-refresh:123';
1240
- mockStorageAdapter.set.mockResolvedValue('locked');
1241
-
1242
- await service.acquireRefreshLock(lockKey, 100); // Very small TTL
1243
-
1244
- const ttlCall = mockStorageAdapter.set.mock.calls[0][2];
1245
- expect(ttlCall).toBeGreaterThanOrEqual(1);
1246
- });
1247
- });
1248
-
1249
- describe('releaseRefreshLock', () => {
1250
- it('should delete lock key from storage', async () => {
1251
- const lockKey = 'session-refresh:123';
1252
- mockStorageAdapter.del.mockResolvedValue(undefined);
1253
-
1254
- await service.releaseRefreshLock(lockKey);
1255
-
1256
- expect(mockStorageAdapter.del).toHaveBeenCalledWith(lockKey);
1257
- });
1258
-
1259
- it('should not throw if lock does not exist', async () => {
1260
- const lockKey = 'nonexistent';
1261
- mockStorageAdapter.del.mockResolvedValue(undefined);
1262
-
1263
- // Should complete without throwing
1264
- await service.releaseRefreshLock(lockKey);
1265
- expect(mockStorageAdapter.del).toHaveBeenCalledWith(lockKey);
1266
- });
1267
- });
1268
-
1269
- // ============================================================================
1270
- // Service Without Optional Dependencies
1271
- // ============================================================================
1272
-
1273
- describe('Service without optional dependencies', () => {
1274
- it('should work without audit service', async () => {
1275
- const serviceWithoutAudit = new SessionService(
1276
- mockSessionRepository,
1277
- mockStorageAdapter,
1278
- mockClientInfoService,
1279
- mockConfig,
1280
- mockLogger,
1281
- undefined, // No audit service
1282
- );
1283
-
1284
- mockSessionRepository.findOne.mockResolvedValue(mockSession as any);
1285
- mockSessionRepository.update.mockResolvedValue({ affected: 1 } as any);
1286
-
1287
- await serviceWithoutAudit.revokeSession(123, 'User logout');
1288
-
1289
- // Should not throw error
1290
- expect(mockSessionRepository.update).toHaveBeenCalled();
1291
- });
1292
- });
1293
- });