@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.
- package/LICENSE +90 -0
- package/README.md +30 -0
- package/package.json +7 -2
- package/jest.config.js +0 -15
- package/jest.setup.ts +0 -6
- package/src/adapters/database-columns.ts +0 -165
- package/src/adapters/express.adapter.ts +0 -385
- package/src/adapters/fastify.adapter.ts +0 -416
- package/src/adapters/index.ts +0 -16
- package/src/adapters/storage.factory.ts +0 -143
- package/src/bootstrap.ts +0 -374
- package/src/dto/auth-challenge.dto.ts +0 -231
- package/src/dto/auth-response.dto.ts +0 -253
- package/src/dto/challenge-response.dto.ts +0 -234
- package/src/dto/change-password-request.dto.ts +0 -50
- package/src/dto/change-password-response.dto.ts +0 -29
- package/src/dto/change-password.dto.ts +0 -57
- package/src/dto/error-response.dto.ts +0 -136
- package/src/dto/get-available-methods.dto.ts +0 -55
- package/src/dto/get-challenge-data-response.dto.ts +0 -28
- package/src/dto/get-challenge-data.dto.ts +0 -69
- package/src/dto/get-client-info.dto.ts +0 -104
- package/src/dto/get-device-token-response.dto.ts +0 -25
- package/src/dto/get-events-by-type.dto.ts +0 -76
- package/src/dto/get-ip-address-response.dto.ts +0 -24
- package/src/dto/get-mfa-status.dto.ts +0 -94
- package/src/dto/get-risk-assessment-history.dto.ts +0 -39
- package/src/dto/get-session-id-response.dto.ts +0 -25
- package/src/dto/get-setup-data-response.dto.ts +0 -31
- package/src/dto/get-setup-data.dto.ts +0 -75
- package/src/dto/get-suspicious-activity.dto.ts +0 -42
- package/src/dto/get-user-agent-response.dto.ts +0 -23
- package/src/dto/get-user-auth-history.dto.ts +0 -95
- package/src/dto/get-user-by-email.dto.ts +0 -61
- package/src/dto/get-user-by-id.dto.ts +0 -46
- package/src/dto/get-user-devices.dto.ts +0 -53
- package/src/dto/get-user-response.dto.ts +0 -17
- package/src/dto/has-provider.dto.ts +0 -56
- package/src/dto/index.ts +0 -57
- package/src/dto/is-trusted-device-response.dto.ts +0 -34
- package/src/dto/list-providers-response.dto.ts +0 -23
- package/src/dto/login.dto.ts +0 -95
- package/src/dto/logout-all-response.dto.ts +0 -24
- package/src/dto/logout-all.dto.ts +0 -65
- package/src/dto/logout-response.dto.ts +0 -25
- package/src/dto/logout.dto.ts +0 -64
- package/src/dto/refresh-token.dto.ts +0 -36
- package/src/dto/remove-devices.dto.ts +0 -85
- package/src/dto/resend-code-response.dto.ts +0 -32
- package/src/dto/resend-code.dto.ts +0 -51
- package/src/dto/reset-password.dto.ts +0 -115
- package/src/dto/respond-challenge.dto.ts +0 -272
- package/src/dto/set-mfa-exemption.dto.ts +0 -112
- package/src/dto/set-must-change-password-response.dto.ts +0 -27
- package/src/dto/set-must-change-password.dto.ts +0 -46
- package/src/dto/set-preferred-method.dto.ts +0 -80
- package/src/dto/setup-mfa.dto.ts +0 -98
- package/src/dto/signup.dto.ts +0 -174
- package/src/dto/social-auth.dto.ts +0 -422
- package/src/dto/trust-device-response.dto.ts +0 -30
- package/src/dto/trust-device.dto.ts +0 -9
- package/src/dto/update-user-attributes-request.dto.ts +0 -51
- package/src/dto/user-response.dto.ts +0 -138
- package/src/dto/user-update.dto.ts +0 -222
- package/src/dto/verify-email.dto.ts +0 -313
- package/src/dto/verify-mfa-code.dto.ts +0 -103
- package/src/dto/verify-phone-by-sub.dto.ts +0 -78
- package/src/dto/verify-phone.dto.ts +0 -245
- package/src/entities/auth-audit.entity.ts +0 -232
- package/src/entities/challenge-session.entity.ts +0 -116
- package/src/entities/index.ts +0 -29
- package/src/entities/login-attempt.entity.ts +0 -64
- package/src/entities/mfa-device.entity.ts +0 -151
- package/src/entities/rate-limit.entity.ts +0 -44
- package/src/entities/session.entity.ts +0 -180
- package/src/entities/social-account.entity.ts +0 -96
- package/src/entities/storage-lock.entity.ts +0 -39
- package/src/entities/trusted-device.entity.ts +0 -112
- package/src/entities/user.entity.ts +0 -243
- package/src/entities/verification-token.entity.ts +0 -141
- package/src/enums/auth-audit-event-type.enum.ts +0 -360
- package/src/enums/error-codes.enum.ts +0 -420
- package/src/enums/mfa-method.enum.ts +0 -97
- package/src/enums/risk-factor.enum.ts +0 -111
- package/src/exceptions/nauth.exception.ts +0 -231
- package/src/handlers/auth.handler.ts +0 -260
- package/src/handlers/client-info.handler.ts +0 -101
- package/src/handlers/csrf.handler.ts +0 -156
- package/src/handlers/token-delivery.handler.ts +0 -118
- package/src/index.ts +0 -118
- package/src/interfaces/client-info.interface.ts +0 -85
- package/src/interfaces/config.interface.ts +0 -2135
- package/src/interfaces/entities.interface.ts +0 -226
- package/src/interfaces/index.ts +0 -15
- package/src/interfaces/logger.interface.ts +0 -283
- package/src/interfaces/mfa-provider.interface.ts +0 -154
- package/src/interfaces/oauth.interface.ts +0 -148
- package/src/interfaces/provider.interface.ts +0 -47
- package/src/interfaces/social-auth-provider.interface.ts +0 -131
- package/src/interfaces/storage-adapter.interface.ts +0 -82
- package/src/interfaces/template.interface.ts +0 -510
- package/src/interfaces/token-verifier.interface.ts +0 -110
- package/src/internal.ts +0 -178
- package/src/platform/interfaces.ts +0 -299
- package/src/schemas/auth-config.schema.ts +0 -646
- package/src/services/adaptive-mfa-decision.service.spec.ts +0 -1058
- package/src/services/adaptive-mfa-decision.service.ts +0 -457
- package/src/services/auth-audit.service.spec.ts +0 -675
- package/src/services/auth-audit.service.ts +0 -558
- package/src/services/auth-challenge-helper.service.spec.ts +0 -3227
- package/src/services/auth-challenge-helper.service.ts +0 -825
- package/src/services/auth-flow-context-builder.service.ts +0 -520
- package/src/services/auth-flow-rules.ts +0 -202
- package/src/services/auth-flow-state-definitions.ts +0 -190
- package/src/services/auth-flow-state-machine.service.ts +0 -207
- package/src/services/auth-flow-state-machine.types.ts +0 -316
- package/src/services/auth.service.spec.ts +0 -4195
- package/src/services/auth.service.ts +0 -3727
- package/src/services/challenge.service.spec.ts +0 -1363
- package/src/services/challenge.service.ts +0 -696
- package/src/services/client-info.service.spec.ts +0 -572
- package/src/services/client-info.service.ts +0 -374
- package/src/services/csrf.service.ts +0 -54
- package/src/services/email-verification.service.spec.ts +0 -1229
- package/src/services/email-verification.service.ts +0 -578
- package/src/services/geo-location.service.spec.ts +0 -603
- package/src/services/geo-location.service.ts +0 -599
- package/src/services/index.ts +0 -13
- package/src/services/jwt.service.spec.ts +0 -882
- package/src/services/jwt.service.ts +0 -621
- package/src/services/mfa-base.service.spec.ts +0 -246
- package/src/services/mfa-base.service.ts +0 -611
- package/src/services/mfa.service.spec.ts +0 -693
- package/src/services/mfa.service.ts +0 -960
- package/src/services/password.service.spec.ts +0 -166
- package/src/services/password.service.ts +0 -309
- package/src/services/phone-verification.service.spec.ts +0 -1120
- package/src/services/phone-verification.service.ts +0 -751
- package/src/services/risk-detection.service.spec.ts +0 -1292
- package/src/services/risk-detection.service.ts +0 -1012
- package/src/services/risk-scoring.service.spec.ts +0 -204
- package/src/services/risk-scoring.service.ts +0 -131
- package/src/services/session.service.spec.ts +0 -1293
- package/src/services/session.service.ts +0 -803
- package/src/services/social-account.service.spec.ts +0 -725
- package/src/services/social-auth-base.service.spec.ts +0 -418
- package/src/services/social-auth-base.service.ts +0 -581
- package/src/services/social-auth.service.spec.ts +0 -238
- package/src/services/social-auth.service.ts +0 -436
- package/src/services/social-provider-registry.service.spec.ts +0 -238
- package/src/services/social-provider-registry.service.ts +0 -122
- package/src/services/trusted-device.service.spec.ts +0 -505
- package/src/services/trusted-device.service.ts +0 -339
- package/src/storage/account-lockout-storage.service.spec.ts +0 -310
- package/src/storage/account-lockout-storage.service.ts +0 -89
- package/src/storage/index.ts +0 -3
- package/src/storage/memory-storage.adapter.ts +0 -443
- package/src/storage/rate-limit-storage.service.spec.ts +0 -247
- package/src/storage/rate-limit-storage.service.ts +0 -38
- package/src/templates/html-template.engine.spec.ts +0 -161
- package/src/templates/html-template.engine.ts +0 -688
- package/src/templates/index.ts +0 -7
- package/src/utils/common-passwords.spec.ts +0 -230
- package/src/utils/common-passwords.ts +0 -170
- package/src/utils/context-storage.ts +0 -188
- package/src/utils/cookie-names.util.ts +0 -67
- package/src/utils/cookies.util.ts +0 -94
- package/src/utils/index.ts +0 -12
- package/src/utils/ip-extractor.spec.ts +0 -330
- package/src/utils/ip-extractor.ts +0 -220
- package/src/utils/nauth-logger.spec.ts +0 -388
- package/src/utils/nauth-logger.ts +0 -215
- package/src/utils/pii-redactor.spec.ts +0 -130
- package/src/utils/pii-redactor.ts +0 -288
- package/src/utils/setup/get-repositories.ts +0 -140
- package/src/utils/setup/init-services.ts +0 -422
- package/src/utils/setup/init-social.ts +0 -189
- package/src/utils/setup/init-storage.ts +0 -94
- package/src/utils/setup/register-mfa.ts +0 -165
- package/src/utils/setup/run-nauth-migrations.ts +0 -61
- package/src/utils/token-delivery-policy.ts +0 -38
- package/src/validators/template.validator.ts +0 -219
- package/tsconfig.json +0 -37
- 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
|
-
});
|