@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.
- package/LICENSE +90 -0
- package/README.md +9 -0
- package/package.json +8 -3
- 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,4195 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AuthService Unit Tests - Comprehensive Coverage
|
|
3
|
-
*
|
|
4
|
-
* Platform-agnostic: Uses direct instantiation, no NestJS dependencies.
|
|
5
|
-
*
|
|
6
|
-
* This is the backbone test suite for the authentication system.
|
|
7
|
-
* Covers all public methods, edge cases, security features, hooks, and error paths.
|
|
8
|
-
*
|
|
9
|
-
* Test Coverage:
|
|
10
|
-
* - User signup (all verification methods, hooks, edge cases)
|
|
11
|
-
* - User login (all scenarios, lockout, MFA, challenges, hooks)
|
|
12
|
-
* - Token refresh (rotation, reuse detection, distributed locking)
|
|
13
|
-
* - Logout operations (single, all, token family)
|
|
14
|
-
* - Password management (change, reset, history, expiry)
|
|
15
|
-
* - Account lockout (IP-based, account-based, unlock)
|
|
16
|
-
* - MFA verification (all methods)
|
|
17
|
-
* - Challenge completion (all challenge types)
|
|
18
|
-
* - Trusted device management
|
|
19
|
-
* - User profile updates
|
|
20
|
-
* - Lifecycle hooks (all hooks)
|
|
21
|
-
* - Optional dependencies handling
|
|
22
|
-
* - Audit logging
|
|
23
|
-
* - Security features (constant-time, token rotation, reuse detection)
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { Repository } from 'typeorm';
|
|
27
|
-
import { AuthService } from './auth.service';
|
|
28
|
-
import { NAuthException } from '../exceptions/nauth.exception';
|
|
29
|
-
import { PasswordService } from './password.service';
|
|
30
|
-
import { JwtService } from './jwt.service';
|
|
31
|
-
import { SessionService } from './session.service';
|
|
32
|
-
import { EmailVerificationService } from './email-verification.service';
|
|
33
|
-
import { PhoneVerificationService } from './phone-verification.service';
|
|
34
|
-
import { ClientInfoService } from './client-info.service';
|
|
35
|
-
import { AccountLockoutStorageService } from '../storage/account-lockout-storage.service';
|
|
36
|
-
import { ChallengeService } from './challenge.service';
|
|
37
|
-
import { AuthChallengeHelperService } from './auth-challenge-helper.service';
|
|
38
|
-
import { AuthAuditService } from './auth-audit.service';
|
|
39
|
-
import { TrustedDeviceService } from './trusted-device.service';
|
|
40
|
-
import { MFAService } from './mfa.service';
|
|
41
|
-
import { SignupDTO } from '../dto/signup.dto';
|
|
42
|
-
import { LoginDTO } from '../dto/login.dto';
|
|
43
|
-
import { AuthChallenge } from '../dto/auth-challenge.dto';
|
|
44
|
-
import { IUser, ISession } from '../interfaces/entities.interface';
|
|
45
|
-
import { BaseUser, BaseLoginAttempt, BaseMFADevice } from '../entities';
|
|
46
|
-
import { NAuthConfig } from '../interfaces/config.interface';
|
|
47
|
-
import { NAuthLogger } from '../utils/nauth-logger';
|
|
48
|
-
import { AuthErrorCode } from '../enums/error-codes.enum';
|
|
49
|
-
import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
|
|
50
|
-
import { RiskFactor } from '../enums/risk-factor.enum';
|
|
51
|
-
import {
|
|
52
|
-
VerifyEmailResponse,
|
|
53
|
-
VerifyPhoneResponse,
|
|
54
|
-
CollectPhoneResponse,
|
|
55
|
-
ForceChangePasswordResponse,
|
|
56
|
-
MFASetupResponse,
|
|
57
|
-
VerifyMFACodeResponse,
|
|
58
|
-
VerifyMFAPasskeyResponse,
|
|
59
|
-
} from '../dto/challenge-response.dto';
|
|
60
|
-
import { RespondChallengeDTO } from '../dto/respond-challenge.dto';
|
|
61
|
-
import { MFAMethod } from '../enums/mfa-method.enum';
|
|
62
|
-
|
|
63
|
-
const createRespondChallengeDto = (data: Partial<RespondChallengeDTO>): RespondChallengeDTO => {
|
|
64
|
-
return Object.assign(new RespondChallengeDTO(), data);
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
describe('AuthService', () => {
|
|
68
|
-
let service: AuthService;
|
|
69
|
-
let mockUserRepository: jest.Mocked<Repository<BaseUser>>;
|
|
70
|
-
let mockLoginAttemptRepository: jest.Mocked<Repository<BaseLoginAttempt>>;
|
|
71
|
-
let mockMfaDeviceRepository: jest.Mocked<Repository<BaseMFADevice>>;
|
|
72
|
-
let mockPasswordService: jest.Mocked<PasswordService>;
|
|
73
|
-
let mockJwtService: jest.Mocked<JwtService>;
|
|
74
|
-
let mockSessionService: jest.Mocked<SessionService>;
|
|
75
|
-
let mockEmailVerificationService: jest.Mocked<EmailVerificationService>;
|
|
76
|
-
let mockPhoneVerificationService: jest.Mocked<PhoneVerificationService>;
|
|
77
|
-
let mockClientInfoService: jest.Mocked<ClientInfoService>;
|
|
78
|
-
let mockAccountLockoutStorage: jest.Mocked<AccountLockoutStorageService>;
|
|
79
|
-
let mockChallengeService: jest.Mocked<ChallengeService>;
|
|
80
|
-
let mockChallengeHelper: jest.Mocked<AuthChallengeHelperService>;
|
|
81
|
-
let mockAuditService: jest.Mocked<AuthAuditService>;
|
|
82
|
-
let mockTrustedDeviceService: jest.Mocked<TrustedDeviceService>;
|
|
83
|
-
let mockMfaService: jest.Mocked<MFAService>;
|
|
84
|
-
let mockLogger: jest.Mocked<NAuthLogger>;
|
|
85
|
-
let mockConfig: NAuthConfig;
|
|
86
|
-
|
|
87
|
-
const mockUser: IUser = {
|
|
88
|
-
id: 1,
|
|
89
|
-
sub: 'user-123',
|
|
90
|
-
email: 'test@example.com',
|
|
91
|
-
username: 'testuser',
|
|
92
|
-
phone: null,
|
|
93
|
-
firstName: 'John',
|
|
94
|
-
lastName: 'Doe',
|
|
95
|
-
passwordHash: 'hashed-password',
|
|
96
|
-
passwordChangedAt: new Date(),
|
|
97
|
-
passwordHistory: [],
|
|
98
|
-
isEmailVerified: true,
|
|
99
|
-
isPhoneVerified: false,
|
|
100
|
-
isActive: true,
|
|
101
|
-
mustChangePassword: false,
|
|
102
|
-
isLocked: false,
|
|
103
|
-
lockReason: null,
|
|
104
|
-
lockedAt: null,
|
|
105
|
-
lockedUntil: null,
|
|
106
|
-
failedLoginAttempts: 0,
|
|
107
|
-
lastFailedLoginAt: null,
|
|
108
|
-
lastLoginAt: null,
|
|
109
|
-
lastLoginIp: null,
|
|
110
|
-
hasSocialAuth: false,
|
|
111
|
-
socialProviders: null,
|
|
112
|
-
mfaEnabled: false,
|
|
113
|
-
mfaMethods: null,
|
|
114
|
-
preferredMfaMethod: null,
|
|
115
|
-
backupCodes: null,
|
|
116
|
-
metadata: null,
|
|
117
|
-
createdAt: new Date(),
|
|
118
|
-
updatedAt: new Date(),
|
|
119
|
-
deletedAt: null,
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
const mockSession: ISession = {
|
|
123
|
-
id: 1,
|
|
124
|
-
userId: 1,
|
|
125
|
-
accessTokenHash: 'access-hash',
|
|
126
|
-
refreshTokenHash: 'refresh-hash',
|
|
127
|
-
tokenFamily: 'family-abc',
|
|
128
|
-
deviceId: 'device-123',
|
|
129
|
-
deviceName: 'Test Device',
|
|
130
|
-
deviceType: 'desktop',
|
|
131
|
-
deviceFingerprint: null,
|
|
132
|
-
ipAddress: '127.0.0.1',
|
|
133
|
-
ipCountry: null,
|
|
134
|
-
ipCity: null,
|
|
135
|
-
ipIsp: null,
|
|
136
|
-
userAgent: 'test-agent',
|
|
137
|
-
platform: 'web',
|
|
138
|
-
browser: 'chrome',
|
|
139
|
-
authMethod: 'password',
|
|
140
|
-
isRemembered: false,
|
|
141
|
-
isTrustedDevice: false,
|
|
142
|
-
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
|
143
|
-
lastActivityAt: new Date(),
|
|
144
|
-
isRevoked: false,
|
|
145
|
-
revokedAt: null,
|
|
146
|
-
revokeReason: null,
|
|
147
|
-
version: 1,
|
|
148
|
-
metadata: null,
|
|
149
|
-
createdAt: new Date(),
|
|
150
|
-
} as ISession;
|
|
151
|
-
|
|
152
|
-
const mockClientInfo: any = {
|
|
153
|
-
ipAddress: '127.0.0.1',
|
|
154
|
-
ipCountry: 'US',
|
|
155
|
-
ipCity: 'San Francisco',
|
|
156
|
-
deviceToken: null,
|
|
157
|
-
deviceName: 'Test Device',
|
|
158
|
-
deviceType: 'desktop',
|
|
159
|
-
userAgent: 'test-agent',
|
|
160
|
-
platform: 'web',
|
|
161
|
-
browser: 'chrome',
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
beforeEach(() => {
|
|
165
|
-
// Create mock repositories
|
|
166
|
-
// Create a fresh query builder mock for each test
|
|
167
|
-
const createMockQueryBuilder = () => ({
|
|
168
|
-
where: jest.fn().mockReturnThis(),
|
|
169
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
170
|
-
select: jest.fn().mockReturnThis(),
|
|
171
|
-
getOne: jest.fn(),
|
|
172
|
-
});
|
|
173
|
-
mockUserRepository = {
|
|
174
|
-
findOne: jest.fn(),
|
|
175
|
-
save: jest.fn(),
|
|
176
|
-
create: jest.fn(),
|
|
177
|
-
update: jest.fn(),
|
|
178
|
-
createQueryBuilder: jest.fn(createMockQueryBuilder),
|
|
179
|
-
} as any;
|
|
180
|
-
|
|
181
|
-
mockLoginAttemptRepository = {
|
|
182
|
-
create: jest.fn(),
|
|
183
|
-
save: jest.fn(),
|
|
184
|
-
} as any;
|
|
185
|
-
|
|
186
|
-
mockMfaDeviceRepository = {
|
|
187
|
-
find: jest.fn(),
|
|
188
|
-
update: jest.fn(),
|
|
189
|
-
} as any;
|
|
190
|
-
|
|
191
|
-
// Create mock services
|
|
192
|
-
mockPasswordService = {
|
|
193
|
-
validatePassword: jest.fn(),
|
|
194
|
-
hashPassword: jest.fn(),
|
|
195
|
-
verifyPassword: jest.fn(),
|
|
196
|
-
isPasswordInHistory: jest.fn(),
|
|
197
|
-
addToHistory: jest.fn(),
|
|
198
|
-
} as any;
|
|
199
|
-
|
|
200
|
-
mockJwtService = {
|
|
201
|
-
generateTokenPair: jest.fn(),
|
|
202
|
-
hashToken: jest.fn(),
|
|
203
|
-
generateTokenFamily: jest.fn(),
|
|
204
|
-
validateAccessToken: jest.fn(),
|
|
205
|
-
validateRefreshToken: jest.fn(),
|
|
206
|
-
decodeToken: jest.fn(),
|
|
207
|
-
getRefreshTokenTTL: jest.fn().mockReturnValue(2592000), // 30 days
|
|
208
|
-
} as any;
|
|
209
|
-
|
|
210
|
-
mockSessionService = {
|
|
211
|
-
createSession: jest.fn(),
|
|
212
|
-
createSessionAtomic: jest.fn(),
|
|
213
|
-
findByRefreshToken: jest.fn(),
|
|
214
|
-
findByIdLight: jest.fn(),
|
|
215
|
-
updateTokens: jest.fn(),
|
|
216
|
-
revokeSession: jest.fn(),
|
|
217
|
-
revokeAllUserSessions: jest.fn(),
|
|
218
|
-
markRefreshTokenAsUsed: jest.fn(),
|
|
219
|
-
isRefreshTokenUsed: jest.fn().mockResolvedValue(false),
|
|
220
|
-
acquireRefreshLock: jest.fn().mockResolvedValue(true),
|
|
221
|
-
releaseRefreshLock: jest.fn(),
|
|
222
|
-
revokeTokenFamily: jest.fn(),
|
|
223
|
-
findById: jest.fn(),
|
|
224
|
-
} as any;
|
|
225
|
-
|
|
226
|
-
mockEmailVerificationService = {
|
|
227
|
-
sendVerificationEmail: jest.fn(),
|
|
228
|
-
} as any;
|
|
229
|
-
|
|
230
|
-
mockPhoneVerificationService = {
|
|
231
|
-
sendVerificationSMS: jest.fn(),
|
|
232
|
-
sendVerificationCode: jest.fn(),
|
|
233
|
-
verifyPhoneWithCode: jest.fn(),
|
|
234
|
-
} as any;
|
|
235
|
-
|
|
236
|
-
mockClientInfoService = {
|
|
237
|
-
get: jest.fn().mockReturnValue(mockClientInfo),
|
|
238
|
-
} as any;
|
|
239
|
-
|
|
240
|
-
mockAccountLockoutStorage = {
|
|
241
|
-
isAccountLocked: jest.fn().mockResolvedValue(false),
|
|
242
|
-
recordFailedAttempt: jest.fn().mockResolvedValue(1),
|
|
243
|
-
resetFailedAttempts: jest.fn().mockResolvedValue(undefined),
|
|
244
|
-
lockAccount: jest.fn().mockResolvedValue(undefined),
|
|
245
|
-
} as any;
|
|
246
|
-
|
|
247
|
-
mockChallengeService = {
|
|
248
|
-
createSession: jest.fn(),
|
|
249
|
-
createChallengeSession: jest.fn(),
|
|
250
|
-
validateSession: jest.fn(),
|
|
251
|
-
validateAndConsumeSession: jest.fn(),
|
|
252
|
-
incrementAttempts: jest.fn(),
|
|
253
|
-
cleanupExpiredSessions: jest.fn(),
|
|
254
|
-
} as any;
|
|
255
|
-
|
|
256
|
-
mockChallengeHelper = {
|
|
257
|
-
determineAuthResponse: jest.fn(),
|
|
258
|
-
createChallengeResponse: jest.fn(),
|
|
259
|
-
createSuccessResponse: jest.fn(),
|
|
260
|
-
createMFAChallengeResponse: jest.fn(),
|
|
261
|
-
createMFASetupChallengeResponse: jest.fn(),
|
|
262
|
-
} as any;
|
|
263
|
-
|
|
264
|
-
mockAuditService = {
|
|
265
|
-
recordEvent: jest.fn().mockResolvedValue(null),
|
|
266
|
-
} as any;
|
|
267
|
-
|
|
268
|
-
mockTrustedDeviceService = {
|
|
269
|
-
isDeviceTrusted: jest.fn().mockResolvedValue(false),
|
|
270
|
-
createTrustedDevice: jest.fn().mockResolvedValue('device-token-123'),
|
|
271
|
-
revokeTrustedDevice: jest.fn().mockResolvedValue(undefined),
|
|
272
|
-
} as any;
|
|
273
|
-
|
|
274
|
-
mockMfaService = {
|
|
275
|
-
verifyCode: jest.fn().mockResolvedValue(true),
|
|
276
|
-
} as any;
|
|
277
|
-
|
|
278
|
-
mockLogger = {
|
|
279
|
-
log: jest.fn(),
|
|
280
|
-
debug: jest.fn(),
|
|
281
|
-
warn: jest.fn(),
|
|
282
|
-
error: jest.fn(),
|
|
283
|
-
verbose: jest.fn(),
|
|
284
|
-
} as any;
|
|
285
|
-
|
|
286
|
-
// Default config
|
|
287
|
-
mockConfig = {
|
|
288
|
-
jwt: {
|
|
289
|
-
algorithm: 'HS256',
|
|
290
|
-
accessToken: {
|
|
291
|
-
secret: 'test-secret',
|
|
292
|
-
expiresIn: 900, // 15 minutes
|
|
293
|
-
},
|
|
294
|
-
refreshToken: {
|
|
295
|
-
secret: 'test-refresh-secret',
|
|
296
|
-
expiresIn: 2592000, // 30 days
|
|
297
|
-
rotation: true,
|
|
298
|
-
reuseDetection: true,
|
|
299
|
-
},
|
|
300
|
-
},
|
|
301
|
-
signup: {
|
|
302
|
-
enabled: true,
|
|
303
|
-
verificationMethod: 'none',
|
|
304
|
-
},
|
|
305
|
-
login: {},
|
|
306
|
-
lockout: {
|
|
307
|
-
enabled: true,
|
|
308
|
-
maxAttempts: 5,
|
|
309
|
-
duration: 900,
|
|
310
|
-
resetOnSuccess: true,
|
|
311
|
-
},
|
|
312
|
-
password: {
|
|
313
|
-
historyCount: 5,
|
|
314
|
-
},
|
|
315
|
-
session: {},
|
|
316
|
-
};
|
|
317
|
-
|
|
318
|
-
// Instantiate service directly
|
|
319
|
-
service = new AuthService(
|
|
320
|
-
mockUserRepository,
|
|
321
|
-
mockLoginAttemptRepository,
|
|
322
|
-
mockPasswordService,
|
|
323
|
-
mockJwtService,
|
|
324
|
-
mockSessionService,
|
|
325
|
-
mockChallengeService,
|
|
326
|
-
mockChallengeHelper,
|
|
327
|
-
mockEmailVerificationService,
|
|
328
|
-
mockClientInfoService,
|
|
329
|
-
mockAccountLockoutStorage,
|
|
330
|
-
mockConfig,
|
|
331
|
-
mockLogger,
|
|
332
|
-
mockAuditService,
|
|
333
|
-
mockPhoneVerificationService,
|
|
334
|
-
mockMfaService,
|
|
335
|
-
mockMfaDeviceRepository,
|
|
336
|
-
mockTrustedDeviceService,
|
|
337
|
-
);
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
afterEach(() => {
|
|
341
|
-
jest.clearAllMocks();
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
// ============================================================================
|
|
345
|
-
// Service Initialization
|
|
346
|
-
// ============================================================================
|
|
347
|
-
|
|
348
|
-
it('should be defined', () => {
|
|
349
|
-
expect(service).toBeDefined();
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it('should log initialization', () => {
|
|
353
|
-
expect(mockLogger.log).toHaveBeenCalledWith('AuthService initialized');
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
// ============================================================================
|
|
357
|
-
// signup Tests
|
|
358
|
-
// ============================================================================
|
|
359
|
-
|
|
360
|
-
describe('signup()', () => {
|
|
361
|
-
const signupDto: SignupDTO = {
|
|
362
|
-
email: 'newuser@example.com',
|
|
363
|
-
password: 'SecurePassword123!',
|
|
364
|
-
username: 'newuser',
|
|
365
|
-
};
|
|
366
|
-
|
|
367
|
-
beforeEach(() => {
|
|
368
|
-
mockChallengeHelper.determineAuthResponse.mockResolvedValue({
|
|
369
|
-
accessToken: 'access-token',
|
|
370
|
-
refreshToken: 'refresh-token',
|
|
371
|
-
accessTokenExpiresAt: Math.floor(Date.now() / 1000) + 900,
|
|
372
|
-
refreshTokenExpiresAt: Math.floor(Date.now() / 1000) + 604800,
|
|
373
|
-
user: {
|
|
374
|
-
sub: 'user-123',
|
|
375
|
-
email: 'newuser@example.com',
|
|
376
|
-
isEmailVerified: true,
|
|
377
|
-
isPhoneVerified: false,
|
|
378
|
-
},
|
|
379
|
-
});
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
describe('Basic signup flow', () => {
|
|
383
|
-
it('should create a new user successfully with verificationMethod: none', async () => {
|
|
384
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
385
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
386
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
387
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
388
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
389
|
-
|
|
390
|
-
const result = await service.signup(signupDto);
|
|
391
|
-
|
|
392
|
-
expect(mockUserRepository.findOne).toHaveBeenCalledWith({ where: { email: signupDto.email } });
|
|
393
|
-
expect(mockPasswordService.validatePassword).toHaveBeenCalled();
|
|
394
|
-
expect(mockPasswordService.hashPassword).toHaveBeenCalledWith(signupDto.password);
|
|
395
|
-
expect(mockUserRepository.save).toHaveBeenCalled();
|
|
396
|
-
expect(mockChallengeHelper.determineAuthResponse).toHaveBeenCalled();
|
|
397
|
-
expect(result.user).toBeDefined();
|
|
398
|
-
expect(result.accessToken).toBe('access-token');
|
|
399
|
-
expect(result.refreshToken).toBe('refresh-token');
|
|
400
|
-
expect(result.challengeName).toBeUndefined();
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it('should hash password with Argon2id', async () => {
|
|
404
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
405
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
406
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
407
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
408
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
409
|
-
|
|
410
|
-
await service.signup(signupDto);
|
|
411
|
-
|
|
412
|
-
expect(mockPasswordService.hashPassword).toHaveBeenCalledWith(signupDto.password);
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
it('should create user with isActive: true and isEmailVerified always false initially', async () => {
|
|
416
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
417
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
418
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
419
|
-
const createdUser = { ...mockUser, email: signupDto.email };
|
|
420
|
-
mockUserRepository.create.mockReturnValue(createdUser as any);
|
|
421
|
-
mockUserRepository.save.mockResolvedValue(createdUser as any);
|
|
422
|
-
|
|
423
|
-
await service.signup(signupDto);
|
|
424
|
-
|
|
425
|
-
expect(mockUserRepository.create).toHaveBeenCalledWith(
|
|
426
|
-
(expect as any).objectContaining({
|
|
427
|
-
email: signupDto.email,
|
|
428
|
-
passwordHash: 'hashed-password',
|
|
429
|
-
isActive: true,
|
|
430
|
-
isEmailVerified: false, // Always false initially - must be explicitly verified
|
|
431
|
-
}),
|
|
432
|
-
);
|
|
433
|
-
});
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
describe('Duplicate checks', () => {
|
|
437
|
-
it('should throw NAuthException if user with email already exists', async () => {
|
|
438
|
-
mockUserRepository.findOne.mockResolvedValue(mockUser as any);
|
|
439
|
-
|
|
440
|
-
try {
|
|
441
|
-
await service.signup(signupDto);
|
|
442
|
-
fail('Should have thrown NAuthException');
|
|
443
|
-
} catch (error: any) {
|
|
444
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
445
|
-
expect(error.code).toBe(AuthErrorCode.EMAIL_EXISTS);
|
|
446
|
-
}
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
it('should throw NAuthException if username already exists', async () => {
|
|
450
|
-
mockUserRepository.findOne.mockImplementation((options: any) => {
|
|
451
|
-
if (options.where?.email) return Promise.resolve(null);
|
|
452
|
-
if (options.where?.username) return Promise.resolve(mockUser as any);
|
|
453
|
-
return Promise.resolve(null);
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
try {
|
|
457
|
-
await service.signup(signupDto);
|
|
458
|
-
fail('Should have thrown NAuthException');
|
|
459
|
-
} catch (error: any) {
|
|
460
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
461
|
-
expect(error.code).toBe(AuthErrorCode.USERNAME_EXISTS);
|
|
462
|
-
}
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
it('should throw NAuthException if phone already exists when allowDuplicatePhones is false', async () => {
|
|
466
|
-
const signupDtoWithPhone: SignupDTO = {
|
|
467
|
-
...signupDto,
|
|
468
|
-
phone: '+1234567890',
|
|
469
|
-
};
|
|
470
|
-
mockConfig.signup!.allowDuplicatePhones = false;
|
|
471
|
-
|
|
472
|
-
mockUserRepository.findOne.mockImplementation((options: any) => {
|
|
473
|
-
if (options.where?.email) return Promise.resolve(null);
|
|
474
|
-
if (options.where?.username) return Promise.resolve(null);
|
|
475
|
-
if (options.where?.phone) return Promise.resolve(mockUser as any);
|
|
476
|
-
return Promise.resolve(null);
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
try {
|
|
480
|
-
await service.signup(signupDtoWithPhone);
|
|
481
|
-
fail('Should have thrown NAuthException');
|
|
482
|
-
} catch (error: any) {
|
|
483
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
484
|
-
expect(error.code).toBe(AuthErrorCode.PHONE_EXISTS);
|
|
485
|
-
}
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
it('should allow duplicate phones when allowDuplicatePhones is true', async () => {
|
|
489
|
-
const signupDtoWithPhone: SignupDTO = {
|
|
490
|
-
...signupDto,
|
|
491
|
-
phone: '+1234567890',
|
|
492
|
-
};
|
|
493
|
-
mockConfig.signup!.allowDuplicatePhones = true;
|
|
494
|
-
|
|
495
|
-
mockUserRepository.findOne.mockImplementation((options: any) => {
|
|
496
|
-
if (options.where?.email) return Promise.resolve(null);
|
|
497
|
-
if (options.where?.username) return Promise.resolve(null);
|
|
498
|
-
// Don't check phone when allowDuplicatePhones is true
|
|
499
|
-
return Promise.resolve(null);
|
|
500
|
-
});
|
|
501
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
502
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
503
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
504
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
505
|
-
|
|
506
|
-
await service.signup(signupDtoWithPhone);
|
|
507
|
-
|
|
508
|
-
// Should not throw
|
|
509
|
-
expect(mockUserRepository.save).toHaveBeenCalled();
|
|
510
|
-
});
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
describe('Password validation', () => {
|
|
514
|
-
it('should throw NAuthException if password is invalid', async () => {
|
|
515
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
516
|
-
mockPasswordService.validatePassword.mockResolvedValue({
|
|
517
|
-
valid: false,
|
|
518
|
-
errors: ['Password is too weak', 'Password must contain uppercase'],
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
try {
|
|
522
|
-
await service.signup(signupDto);
|
|
523
|
-
fail('Should have thrown NAuthException');
|
|
524
|
-
} catch (error: any) {
|
|
525
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
526
|
-
// Password validation happens in handleForceChangePassword, which throws WEAK_PASSWORD
|
|
527
|
-
// But validation might happen earlier in validateChallengeParams
|
|
528
|
-
expect([AuthErrorCode.WEAK_PASSWORD, AuthErrorCode.VALIDATION_FAILED]).toContain(error.code);
|
|
529
|
-
expect(error.message).toContain('Password is too weak');
|
|
530
|
-
}
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
it('should pass email and username to password validation', async () => {
|
|
534
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
535
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
536
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
537
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
538
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
539
|
-
|
|
540
|
-
await service.signup(signupDto);
|
|
541
|
-
|
|
542
|
-
expect(mockPasswordService.validatePassword).toHaveBeenCalledWith(signupDto.password, {
|
|
543
|
-
email: signupDto.email,
|
|
544
|
-
username: signupDto.username,
|
|
545
|
-
});
|
|
546
|
-
});
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
describe('Verification method: email', () => {
|
|
550
|
-
beforeEach(() => {
|
|
551
|
-
mockConfig.signup!.verificationMethod = 'email';
|
|
552
|
-
mockChallengeHelper.determineAuthResponse.mockResolvedValue({
|
|
553
|
-
challengeName: AuthChallenge.VERIFY_EMAIL,
|
|
554
|
-
session: 'session-token-123',
|
|
555
|
-
challengeParameters: {
|
|
556
|
-
email: signupDto.email,
|
|
557
|
-
instructions: 'Please verify your email address',
|
|
558
|
-
},
|
|
559
|
-
});
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
it('should return VERIFY_EMAIL challenge', async () => {
|
|
563
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
564
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
565
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
566
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
567
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
568
|
-
mockEmailVerificationService.sendVerificationEmail.mockResolvedValue(123);
|
|
569
|
-
|
|
570
|
-
const result = await service.signup(signupDto);
|
|
571
|
-
|
|
572
|
-
expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
|
|
573
|
-
expect(result.session).toBe('session-token-123');
|
|
574
|
-
expect(result.accessToken).toBeUndefined();
|
|
575
|
-
expect(result.refreshToken).toBeUndefined();
|
|
576
|
-
expect(mockEmailVerificationService.sendVerificationEmail).toHaveBeenCalled();
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
it('should create user with isEmailVerified: false', async () => {
|
|
580
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
581
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
582
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
583
|
-
const createdUser = { ...mockUser, email: signupDto.email, isEmailVerified: false };
|
|
584
|
-
mockUserRepository.create.mockReturnValue(createdUser as any);
|
|
585
|
-
mockUserRepository.save.mockResolvedValue(createdUser as any);
|
|
586
|
-
mockEmailVerificationService.sendVerificationEmail.mockResolvedValue(123);
|
|
587
|
-
|
|
588
|
-
await service.signup(signupDto);
|
|
589
|
-
|
|
590
|
-
expect(mockUserRepository.create).toHaveBeenCalledWith(
|
|
591
|
-
(expect as any).objectContaining({
|
|
592
|
-
isEmailVerified: false,
|
|
593
|
-
}),
|
|
594
|
-
);
|
|
595
|
-
});
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
describe('Verification method: phone', () => {
|
|
599
|
-
beforeEach(() => {
|
|
600
|
-
mockConfig.signup!.verificationMethod = 'phone';
|
|
601
|
-
mockChallengeHelper.determineAuthResponse.mockResolvedValue({
|
|
602
|
-
challengeName: AuthChallenge.VERIFY_PHONE,
|
|
603
|
-
session: 'session-token-123',
|
|
604
|
-
challengeParameters: {
|
|
605
|
-
phone: '+1234567890',
|
|
606
|
-
instructions: 'Please verify your phone number',
|
|
607
|
-
},
|
|
608
|
-
});
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
it('should throw NAuthException if phone is required but not provided', async () => {
|
|
612
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
613
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
614
|
-
|
|
615
|
-
try {
|
|
616
|
-
await service.signup(signupDto);
|
|
617
|
-
fail('Should have thrown NAuthException');
|
|
618
|
-
} catch (error: any) {
|
|
619
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
620
|
-
expect(error.code).toBe(AuthErrorCode.PHONE_REQUIRED);
|
|
621
|
-
}
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
it('should return VERIFY_PHONE challenge when phone is provided', async () => {
|
|
625
|
-
const signupDtoWithPhone: SignupDTO = {
|
|
626
|
-
...signupDto,
|
|
627
|
-
phone: '+1234567890',
|
|
628
|
-
};
|
|
629
|
-
|
|
630
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
631
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
632
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
633
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
634
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
635
|
-
|
|
636
|
-
const result = await service.signup(signupDtoWithPhone);
|
|
637
|
-
|
|
638
|
-
expect(result.challengeName).toBe(AuthChallenge.VERIFY_PHONE);
|
|
639
|
-
// Phone verification SMS is sent during challenge completion, not during signup
|
|
640
|
-
});
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
describe('Verification method: both', () => {
|
|
644
|
-
beforeEach(() => {
|
|
645
|
-
mockConfig.signup!.verificationMethod = 'both';
|
|
646
|
-
// Sequential challenges: first VERIFY_EMAIL, then VERIFY_PHONE
|
|
647
|
-
mockChallengeHelper.determineAuthResponse.mockResolvedValue({
|
|
648
|
-
challengeName: AuthChallenge.VERIFY_EMAIL,
|
|
649
|
-
session: 'session-token-email',
|
|
650
|
-
challengeParameters: {
|
|
651
|
-
email: signupDto.email,
|
|
652
|
-
codeDeliveryDestination: 't***@example.com',
|
|
653
|
-
},
|
|
654
|
-
});
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
it('should throw NAuthException if phone is required but not provided', async () => {
|
|
658
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
659
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
660
|
-
|
|
661
|
-
try {
|
|
662
|
-
await service.signup(signupDto);
|
|
663
|
-
fail('Should have thrown NAuthException');
|
|
664
|
-
} catch (error: any) {
|
|
665
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
666
|
-
expect(error.code).toBe(AuthErrorCode.PHONE_REQUIRED);
|
|
667
|
-
}
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
it('should return VERIFY_EMAIL challenge first when both are provided (sequential flow)', async () => {
|
|
671
|
-
const signupDtoWithPhone: SignupDTO = {
|
|
672
|
-
...signupDto,
|
|
673
|
-
phone: '+1234567890',
|
|
674
|
-
};
|
|
675
|
-
|
|
676
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
677
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
678
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
679
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
680
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
681
|
-
mockEmailVerificationService.sendVerificationEmail.mockResolvedValue(123);
|
|
682
|
-
|
|
683
|
-
const result = await service.signup(signupDtoWithPhone);
|
|
684
|
-
|
|
685
|
-
// Sequential challenges: first VERIFY_EMAIL, then VERIFY_PHONE after email is verified
|
|
686
|
-
expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
|
|
687
|
-
// When verificationMethod is 'both', email is sent by challenge system when VERIFY_EMAIL challenge is created
|
|
688
|
-
// Phone verification SMS is sent when VERIFY_PHONE challenge is created (after email is verified)
|
|
689
|
-
});
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
describe('Lifecycle hooks', () => {
|
|
693
|
-
it('should execute beforeSignup hook and reject if returns false', async () => {
|
|
694
|
-
mockConfig.hooks = {
|
|
695
|
-
beforeSignup: jest.fn().mockResolvedValue(false),
|
|
696
|
-
};
|
|
697
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
698
|
-
|
|
699
|
-
try {
|
|
700
|
-
await service.signup(signupDto);
|
|
701
|
-
fail('Should have thrown NAuthException');
|
|
702
|
-
} catch (error: any) {
|
|
703
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
704
|
-
expect(error.code).toBe(AuthErrorCode.SIGNUP_NOT_ALLOWED);
|
|
705
|
-
expect(mockConfig.hooks!.beforeSignup).toHaveBeenCalledWith(signupDto);
|
|
706
|
-
}
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
it('should allow signup if beforeSignup hook returns true', async () => {
|
|
710
|
-
mockConfig.hooks = {
|
|
711
|
-
beforeSignup: jest.fn().mockResolvedValue(true),
|
|
712
|
-
};
|
|
713
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
714
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
715
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
716
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
717
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
718
|
-
|
|
719
|
-
await service.signup(signupDto);
|
|
720
|
-
|
|
721
|
-
expect(mockConfig.hooks!.beforeSignup).toHaveBeenCalled();
|
|
722
|
-
expect(mockUserRepository.save).toHaveBeenCalled();
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
it('should execute afterSignup hook after successful signup', async () => {
|
|
726
|
-
mockConfig.hooks = {
|
|
727
|
-
afterSignup: jest.fn().mockResolvedValue(undefined),
|
|
728
|
-
};
|
|
729
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
730
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
731
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
732
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
733
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
734
|
-
|
|
735
|
-
await service.signup(signupDto);
|
|
736
|
-
|
|
737
|
-
expect(mockConfig.hooks!.afterSignup).toHaveBeenCalled();
|
|
738
|
-
});
|
|
739
|
-
});
|
|
740
|
-
|
|
741
|
-
describe('Signup disabled', () => {
|
|
742
|
-
it('should throw NAuthException if signup is disabled', async () => {
|
|
743
|
-
mockConfig.signup!.enabled = false;
|
|
744
|
-
|
|
745
|
-
try {
|
|
746
|
-
await service.signup(signupDto);
|
|
747
|
-
fail('Should have thrown NAuthException');
|
|
748
|
-
} catch (error: any) {
|
|
749
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
750
|
-
expect(error.code).toBe(AuthErrorCode.SIGNUP_DISABLED);
|
|
751
|
-
}
|
|
752
|
-
});
|
|
753
|
-
});
|
|
754
|
-
|
|
755
|
-
describe('Database constraint violations', () => {
|
|
756
|
-
it('should handle PostgreSQL unique constraint violation for email', async () => {
|
|
757
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
758
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
759
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
760
|
-
const dbError = {
|
|
761
|
-
code: '23505',
|
|
762
|
-
detail: 'Key (email)=(newuser@example.com) already exists.',
|
|
763
|
-
message: 'duplicate key value violates unique constraint',
|
|
764
|
-
};
|
|
765
|
-
mockUserRepository.save.mockRejectedValue(dbError);
|
|
766
|
-
|
|
767
|
-
try {
|
|
768
|
-
await service.signup(signupDto);
|
|
769
|
-
fail('Should have thrown NAuthException');
|
|
770
|
-
} catch (error: any) {
|
|
771
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
772
|
-
expect(error.code).toBe(AuthErrorCode.EMAIL_EXISTS);
|
|
773
|
-
}
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
it('should handle PostgreSQL unique constraint violation for username', async () => {
|
|
777
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
778
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
779
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
780
|
-
const dbError = {
|
|
781
|
-
code: '23505',
|
|
782
|
-
detail: 'Key (username)=(newuser) already exists.',
|
|
783
|
-
message: 'duplicate key value violates unique constraint',
|
|
784
|
-
};
|
|
785
|
-
mockUserRepository.save.mockRejectedValue(dbError);
|
|
786
|
-
|
|
787
|
-
try {
|
|
788
|
-
await service.signup(signupDto);
|
|
789
|
-
fail('Should have thrown NAuthException');
|
|
790
|
-
} catch (error: any) {
|
|
791
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
792
|
-
expect(error.code).toBe(AuthErrorCode.USERNAME_EXISTS);
|
|
793
|
-
}
|
|
794
|
-
});
|
|
795
|
-
|
|
796
|
-
it('should handle PostgreSQL unique constraint violation for phone', async () => {
|
|
797
|
-
const signupDtoWithPhone: SignupDTO = {
|
|
798
|
-
...signupDto,
|
|
799
|
-
phone: '+1234567890',
|
|
800
|
-
};
|
|
801
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
802
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
803
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
804
|
-
const dbError = {
|
|
805
|
-
code: '23505',
|
|
806
|
-
detail: 'Key (phone)=(+1234567890) already exists.',
|
|
807
|
-
message: 'duplicate key value violates unique constraint',
|
|
808
|
-
};
|
|
809
|
-
mockUserRepository.save.mockRejectedValue(dbError);
|
|
810
|
-
|
|
811
|
-
try {
|
|
812
|
-
await service.signup(signupDtoWithPhone);
|
|
813
|
-
fail('Should have thrown NAuthException');
|
|
814
|
-
} catch (error: any) {
|
|
815
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
816
|
-
expect(error.code).toBe(AuthErrorCode.PHONE_EXISTS);
|
|
817
|
-
}
|
|
818
|
-
});
|
|
819
|
-
});
|
|
820
|
-
|
|
821
|
-
describe('Audit logging', () => {
|
|
822
|
-
it('should record ACCOUNT_CREATED audit event on successful signup', async () => {
|
|
823
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
824
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
825
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
826
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
827
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
828
|
-
|
|
829
|
-
await service.signup(signupDto);
|
|
830
|
-
|
|
831
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
832
|
-
(expect as any).objectContaining({
|
|
833
|
-
userId: mockUser.id,
|
|
834
|
-
eventType: AuthAuditEventType.ACCOUNT_CREATED,
|
|
835
|
-
eventStatus: 'INFO',
|
|
836
|
-
authMethod: 'password',
|
|
837
|
-
}),
|
|
838
|
-
);
|
|
839
|
-
});
|
|
840
|
-
|
|
841
|
-
it('should handle audit logging errors gracefully', async () => {
|
|
842
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
843
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
844
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
845
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
846
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
847
|
-
mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
|
|
848
|
-
|
|
849
|
-
const result = await service.signup(signupDto);
|
|
850
|
-
|
|
851
|
-
// Should still succeed despite audit error
|
|
852
|
-
expect(result.user).toBeDefined();
|
|
853
|
-
expect(mockLogger.error).toHaveBeenCalled();
|
|
854
|
-
});
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
describe('Optional fields', () => {
|
|
858
|
-
it('should handle signup without username', async () => {
|
|
859
|
-
const signupDtoNoUsername: SignupDTO = {
|
|
860
|
-
email: 'newuser@example.com',
|
|
861
|
-
password: 'SecurePassword123!',
|
|
862
|
-
};
|
|
863
|
-
|
|
864
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
865
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
866
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
867
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
868
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
869
|
-
|
|
870
|
-
await service.signup(signupDtoNoUsername);
|
|
871
|
-
|
|
872
|
-
expect(mockUserRepository.findOne).not.toHaveBeenCalledWith(
|
|
873
|
-
(expect as any).objectContaining({
|
|
874
|
-
where: (expect as any).objectContaining({ username: (expect as any).anything() }),
|
|
875
|
-
}),
|
|
876
|
-
);
|
|
877
|
-
});
|
|
878
|
-
|
|
879
|
-
it('should handle signup with firstName and lastName', async () => {
|
|
880
|
-
const signupDtoWithName: SignupDTO = {
|
|
881
|
-
...signupDto,
|
|
882
|
-
firstName: 'John',
|
|
883
|
-
lastName: 'Doe',
|
|
884
|
-
};
|
|
885
|
-
|
|
886
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
887
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
888
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
889
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
890
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
891
|
-
|
|
892
|
-
await service.signup(signupDtoWithName);
|
|
893
|
-
|
|
894
|
-
expect(mockUserRepository.create).toHaveBeenCalledWith(
|
|
895
|
-
(expect as any).objectContaining({
|
|
896
|
-
firstName: 'John',
|
|
897
|
-
lastName: 'Doe',
|
|
898
|
-
}),
|
|
899
|
-
);
|
|
900
|
-
});
|
|
901
|
-
|
|
902
|
-
it('should handle signup with metadata', async () => {
|
|
903
|
-
const signupDtoWithMetadata: SignupDTO = {
|
|
904
|
-
...signupDto,
|
|
905
|
-
metadata: { customField: 'value' },
|
|
906
|
-
};
|
|
907
|
-
|
|
908
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
909
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
910
|
-
mockPasswordService.hashPassword.mockResolvedValue('hashed-password');
|
|
911
|
-
mockUserRepository.create.mockReturnValue(mockUser as any);
|
|
912
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
913
|
-
|
|
914
|
-
await service.signup(signupDtoWithMetadata);
|
|
915
|
-
|
|
916
|
-
expect(mockUserRepository.create).toHaveBeenCalledWith(
|
|
917
|
-
(expect as any).objectContaining({
|
|
918
|
-
metadata: { customField: 'value' },
|
|
919
|
-
}),
|
|
920
|
-
);
|
|
921
|
-
});
|
|
922
|
-
});
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
// ============================================================================
|
|
926
|
-
// login Tests
|
|
927
|
-
// ============================================================================
|
|
928
|
-
|
|
929
|
-
describe('login()', () => {
|
|
930
|
-
const loginDto: LoginDTO = {
|
|
931
|
-
identifier: 'test@example.com',
|
|
932
|
-
password: 'SecurePassword123!',
|
|
933
|
-
};
|
|
934
|
-
|
|
935
|
-
beforeEach(() => {
|
|
936
|
-
mockChallengeHelper.determineAuthResponse.mockResolvedValue({
|
|
937
|
-
accessToken: 'access-token',
|
|
938
|
-
refreshToken: 'refresh-token',
|
|
939
|
-
accessTokenExpiresAt: Math.floor(Date.now() / 1000) + 900,
|
|
940
|
-
refreshTokenExpiresAt: Math.floor(Date.now() / 1000) + 604800,
|
|
941
|
-
user: {
|
|
942
|
-
sub: mockUser.sub,
|
|
943
|
-
email: mockUser.email,
|
|
944
|
-
isEmailVerified: true,
|
|
945
|
-
isPhoneVerified: false,
|
|
946
|
-
},
|
|
947
|
-
});
|
|
948
|
-
});
|
|
949
|
-
|
|
950
|
-
describe('Successful login', () => {
|
|
951
|
-
beforeEach(() => {
|
|
952
|
-
const queryBuilder = {
|
|
953
|
-
where: jest.fn().mockReturnThis(),
|
|
954
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
955
|
-
select: jest.fn().mockReturnThis(),
|
|
956
|
-
getOne: jest.fn().mockResolvedValue(mockUser),
|
|
957
|
-
};
|
|
958
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
959
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
960
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
961
|
-
mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
|
|
962
|
-
mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
|
|
963
|
-
mockJwtService.generateTokenPair.mockResolvedValue({
|
|
964
|
-
accessToken: 'access-token',
|
|
965
|
-
refreshToken: 'refresh-token',
|
|
966
|
-
expiresIn: 900,
|
|
967
|
-
});
|
|
968
|
-
mockJwtService.hashToken.mockReturnValue('token-hash');
|
|
969
|
-
mockJwtService.validateAccessToken.mockResolvedValue({
|
|
970
|
-
valid: true,
|
|
971
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 900 },
|
|
972
|
-
} as any);
|
|
973
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
974
|
-
valid: true,
|
|
975
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
|
|
976
|
-
} as any);
|
|
977
|
-
mockSessionService.createSessionAtomic.mockResolvedValue({
|
|
978
|
-
session: mockSession,
|
|
979
|
-
extra: {
|
|
980
|
-
accessToken: 'access-token',
|
|
981
|
-
refreshToken: 'refresh-token',
|
|
982
|
-
},
|
|
983
|
-
} as any);
|
|
984
|
-
mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
|
|
985
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
986
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
987
|
-
});
|
|
988
|
-
|
|
989
|
-
it('should login user successfully and return tokens', async () => {
|
|
990
|
-
const result = await service.login(loginDto);
|
|
991
|
-
|
|
992
|
-
expect(result.user).toBeDefined();
|
|
993
|
-
expect(result.accessToken).toBe('access-token');
|
|
994
|
-
expect(result.refreshToken).toBe('refresh-token');
|
|
995
|
-
expect(mockPasswordService.verifyPassword).toHaveBeenCalledWith(loginDto.password, mockUser.passwordHash!);
|
|
996
|
-
expect(mockSessionService.createSessionAtomic).toHaveBeenCalled();
|
|
997
|
-
expect(mockAccountLockoutStorage.resetFailedAttempts).toHaveBeenCalled();
|
|
998
|
-
});
|
|
999
|
-
|
|
1000
|
-
it('should update user lastLoginAt and lastLoginIp on successful login', async () => {
|
|
1001
|
-
await service.login(loginDto);
|
|
1002
|
-
|
|
1003
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
1004
|
-
mockUser.id,
|
|
1005
|
-
(expect as any).objectContaining({
|
|
1006
|
-
lastLoginAt: (expect as any).any(Date),
|
|
1007
|
-
lastLoginIp: mockClientInfo.ipAddress!,
|
|
1008
|
-
failedLoginAttempts: 0,
|
|
1009
|
-
}),
|
|
1010
|
-
);
|
|
1011
|
-
});
|
|
1012
|
-
|
|
1013
|
-
it('should record successful login attempt', async () => {
|
|
1014
|
-
await service.login(loginDto);
|
|
1015
|
-
|
|
1016
|
-
expect(mockLoginAttemptRepository.create).toHaveBeenCalled();
|
|
1017
|
-
expect(mockLoginAttemptRepository.save).toHaveBeenCalled();
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
it('should reset failed login attempts on successful login', async () => {
|
|
1021
|
-
await service.login(loginDto);
|
|
1022
|
-
|
|
1023
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
1024
|
-
mockUser.id,
|
|
1025
|
-
(expect as any).objectContaining({
|
|
1026
|
-
failedLoginAttempts: 0,
|
|
1027
|
-
}),
|
|
1028
|
-
);
|
|
1029
|
-
});
|
|
1030
|
-
|
|
1031
|
-
it('should record LOGIN_SUCCESS audit event', async () => {
|
|
1032
|
-
await service.login(loginDto);
|
|
1033
|
-
|
|
1034
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
1035
|
-
(expect as any).objectContaining({
|
|
1036
|
-
userId: mockUser.id,
|
|
1037
|
-
eventType: AuthAuditEventType.LOGIN_SUCCESS,
|
|
1038
|
-
eventStatus: 'SUCCESS',
|
|
1039
|
-
authMethod: 'password',
|
|
1040
|
-
}),
|
|
1041
|
-
);
|
|
1042
|
-
});
|
|
1043
|
-
|
|
1044
|
-
it('should execute afterLogin hook on successful login', async () => {
|
|
1045
|
-
mockConfig.hooks = {
|
|
1046
|
-
afterLogin: jest.fn().mockResolvedValue(undefined),
|
|
1047
|
-
};
|
|
1048
|
-
|
|
1049
|
-
await service.login(loginDto);
|
|
1050
|
-
|
|
1051
|
-
expect(mockConfig.hooks!.afterLogin).toHaveBeenCalledWith(mockUser, mockSession);
|
|
1052
|
-
});
|
|
1053
|
-
});
|
|
1054
|
-
|
|
1055
|
-
describe('User lookup by identifier', () => {
|
|
1056
|
-
it('should find user by email', async () => {
|
|
1057
|
-
const queryBuilder = {
|
|
1058
|
-
where: jest.fn().mockReturnThis(),
|
|
1059
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1060
|
-
select: jest.fn().mockReturnThis(),
|
|
1061
|
-
getOne: jest.fn().mockResolvedValue(mockUser),
|
|
1062
|
-
};
|
|
1063
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1064
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1065
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
1066
|
-
mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
|
|
1067
|
-
mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
|
|
1068
|
-
mockJwtService.generateTokenPair.mockResolvedValue({
|
|
1069
|
-
accessToken: 'access-token',
|
|
1070
|
-
refreshToken: 'refresh-token',
|
|
1071
|
-
expiresIn: 900,
|
|
1072
|
-
});
|
|
1073
|
-
mockJwtService.hashToken.mockReturnValue('token-hash');
|
|
1074
|
-
mockJwtService.validateAccessToken.mockResolvedValue({
|
|
1075
|
-
valid: true,
|
|
1076
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 900 },
|
|
1077
|
-
} as any);
|
|
1078
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
1079
|
-
valid: true,
|
|
1080
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
|
|
1081
|
-
} as any);
|
|
1082
|
-
mockSessionService.createSessionAtomic.mockResolvedValue({
|
|
1083
|
-
session: mockSession,
|
|
1084
|
-
extra: {
|
|
1085
|
-
accessToken: 'access-token',
|
|
1086
|
-
refreshToken: 'refresh-token',
|
|
1087
|
-
},
|
|
1088
|
-
} as any);
|
|
1089
|
-
mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
|
|
1090
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1091
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1092
|
-
|
|
1093
|
-
await service.login({ identifier: 'test@example.com', password: 'password' });
|
|
1094
|
-
|
|
1095
|
-
expect(queryBuilder.where).toHaveBeenCalledWith('user.email = :identifier', { identifier: 'test@example.com' });
|
|
1096
|
-
});
|
|
1097
|
-
|
|
1098
|
-
it('should find user by username when identifierType is email_or_username', async () => {
|
|
1099
|
-
mockConfig.login!.identifierType = 'email_or_username';
|
|
1100
|
-
const queryBuilder = {
|
|
1101
|
-
where: jest.fn().mockReturnThis(),
|
|
1102
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1103
|
-
select: jest.fn().mockReturnThis(),
|
|
1104
|
-
getOne: jest.fn().mockResolvedValue(mockUser),
|
|
1105
|
-
};
|
|
1106
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1107
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1108
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
1109
|
-
mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
|
|
1110
|
-
mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
|
|
1111
|
-
mockJwtService.generateTokenPair.mockResolvedValue({
|
|
1112
|
-
accessToken: 'access-token',
|
|
1113
|
-
refreshToken: 'refresh-token',
|
|
1114
|
-
expiresIn: 900,
|
|
1115
|
-
});
|
|
1116
|
-
mockJwtService.hashToken.mockReturnValue('token-hash');
|
|
1117
|
-
mockJwtService.validateAccessToken.mockResolvedValue({
|
|
1118
|
-
valid: true,
|
|
1119
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 900 },
|
|
1120
|
-
} as any);
|
|
1121
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
1122
|
-
valid: true,
|
|
1123
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
|
|
1124
|
-
} as any);
|
|
1125
|
-
mockSessionService.createSessionAtomic.mockResolvedValue({
|
|
1126
|
-
session: mockSession,
|
|
1127
|
-
extra: {
|
|
1128
|
-
accessToken: 'access-token',
|
|
1129
|
-
refreshToken: 'refresh-token',
|
|
1130
|
-
},
|
|
1131
|
-
} as any);
|
|
1132
|
-
mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
|
|
1133
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1134
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1135
|
-
|
|
1136
|
-
await service.login({ identifier: 'testuser', password: 'password' });
|
|
1137
|
-
|
|
1138
|
-
expect(queryBuilder.where).toHaveBeenCalled();
|
|
1139
|
-
expect(queryBuilder.orWhere).toHaveBeenCalled();
|
|
1140
|
-
});
|
|
1141
|
-
|
|
1142
|
-
it('should throw NAuthException when identifierType is email but username provided', async () => {
|
|
1143
|
-
mockConfig.login!.identifierType = 'email';
|
|
1144
|
-
const queryBuilder = {
|
|
1145
|
-
where: jest.fn().mockReturnThis(),
|
|
1146
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1147
|
-
select: jest.fn().mockReturnThis(),
|
|
1148
|
-
getOne: jest.fn().mockResolvedValue(null),
|
|
1149
|
-
};
|
|
1150
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1151
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1152
|
-
mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
|
|
1153
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1154
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1155
|
-
|
|
1156
|
-
try {
|
|
1157
|
-
await service.login({ identifier: 'testuser', password: 'password' });
|
|
1158
|
-
fail('Should have thrown NAuthException');
|
|
1159
|
-
} catch (error: any) {
|
|
1160
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
1161
|
-
expect(error.code).toBe(AuthErrorCode.INVALID_CREDENTIALS);
|
|
1162
|
-
}
|
|
1163
|
-
});
|
|
1164
|
-
});
|
|
1165
|
-
|
|
1166
|
-
describe('IP-based lockout', () => {
|
|
1167
|
-
it('should throw NAuthException if IP address is locked', async () => {
|
|
1168
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(true);
|
|
1169
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1170
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1171
|
-
|
|
1172
|
-
try {
|
|
1173
|
-
await service.login(loginDto);
|
|
1174
|
-
fail('Should have thrown NAuthException');
|
|
1175
|
-
} catch (error: any) {
|
|
1176
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
1177
|
-
expect(error.code).toBe(AuthErrorCode.RATE_LIMIT_LOGIN);
|
|
1178
|
-
}
|
|
1179
|
-
});
|
|
1180
|
-
|
|
1181
|
-
it('should record LOGIN_BLOCKED audit event when IP is locked', async () => {
|
|
1182
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(true);
|
|
1183
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1184
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1185
|
-
|
|
1186
|
-
try {
|
|
1187
|
-
await service.login(loginDto);
|
|
1188
|
-
fail('Should have thrown NAuthException');
|
|
1189
|
-
} catch (error: any) {
|
|
1190
|
-
// Expected to throw
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
1194
|
-
(expect as any).objectContaining({
|
|
1195
|
-
eventType: AuthAuditEventType.LOGIN_BLOCKED,
|
|
1196
|
-
eventStatus: 'FAILURE',
|
|
1197
|
-
reason: 'ip_locked',
|
|
1198
|
-
}),
|
|
1199
|
-
);
|
|
1200
|
-
});
|
|
1201
|
-
|
|
1202
|
-
it('should reset IP-based failed attempts on successful login when resetOnSuccess is true', async () => {
|
|
1203
|
-
mockConfig.lockout!.resetOnSuccess = true;
|
|
1204
|
-
const queryBuilder = {
|
|
1205
|
-
where: jest.fn().mockReturnThis(),
|
|
1206
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1207
|
-
select: jest.fn().mockReturnThis(),
|
|
1208
|
-
getOne: jest.fn().mockResolvedValue(mockUser),
|
|
1209
|
-
};
|
|
1210
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1211
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1212
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
1213
|
-
mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
|
|
1214
|
-
mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
|
|
1215
|
-
mockJwtService.generateTokenPair.mockResolvedValue({
|
|
1216
|
-
accessToken: 'access-token',
|
|
1217
|
-
refreshToken: 'refresh-token',
|
|
1218
|
-
expiresIn: 900,
|
|
1219
|
-
});
|
|
1220
|
-
mockJwtService.hashToken.mockReturnValue('token-hash');
|
|
1221
|
-
mockJwtService.validateAccessToken.mockResolvedValue({
|
|
1222
|
-
valid: true,
|
|
1223
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 900 },
|
|
1224
|
-
} as any);
|
|
1225
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
1226
|
-
valid: true,
|
|
1227
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
|
|
1228
|
-
} as any);
|
|
1229
|
-
mockSessionService.createSessionAtomic.mockResolvedValue({
|
|
1230
|
-
session: mockSession,
|
|
1231
|
-
extra: {
|
|
1232
|
-
accessToken: 'access-token',
|
|
1233
|
-
refreshToken: 'refresh-token',
|
|
1234
|
-
},
|
|
1235
|
-
} as any);
|
|
1236
|
-
mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
|
|
1237
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1238
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1239
|
-
|
|
1240
|
-
await service.login(loginDto);
|
|
1241
|
-
|
|
1242
|
-
expect(mockAccountLockoutStorage.resetFailedAttempts).toHaveBeenCalledWith(mockClientInfo.ipAddress);
|
|
1243
|
-
});
|
|
1244
|
-
});
|
|
1245
|
-
|
|
1246
|
-
describe('Invalid credentials', () => {
|
|
1247
|
-
it('should throw NAuthException if user not found (constant-time response)', async () => {
|
|
1248
|
-
const queryBuilder = {
|
|
1249
|
-
where: jest.fn().mockReturnThis(),
|
|
1250
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1251
|
-
select: jest.fn().mockReturnThis(),
|
|
1252
|
-
getOne: jest.fn().mockResolvedValue(null),
|
|
1253
|
-
};
|
|
1254
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1255
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1256
|
-
// Password verification still called with dummy hash (constant-time)
|
|
1257
|
-
mockPasswordService.verifyPassword.mockResolvedValue(false);
|
|
1258
|
-
mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
|
|
1259
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1260
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1261
|
-
|
|
1262
|
-
try {
|
|
1263
|
-
await service.login(loginDto);
|
|
1264
|
-
fail('Should have thrown NAuthException');
|
|
1265
|
-
} catch (error: any) {
|
|
1266
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
1267
|
-
expect(error.code).toBe(AuthErrorCode.INVALID_CREDENTIALS);
|
|
1268
|
-
}
|
|
1269
|
-
// Verify password was still called (constant-time protection)
|
|
1270
|
-
expect(mockPasswordService.verifyPassword).toHaveBeenCalled();
|
|
1271
|
-
});
|
|
1272
|
-
|
|
1273
|
-
it('should throw NAuthException if password is invalid', async () => {
|
|
1274
|
-
const queryBuilder = {
|
|
1275
|
-
where: jest.fn().mockReturnThis(),
|
|
1276
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1277
|
-
select: jest.fn().mockReturnThis(),
|
|
1278
|
-
getOne: jest.fn().mockResolvedValue(mockUser),
|
|
1279
|
-
};
|
|
1280
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1281
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1282
|
-
mockPasswordService.verifyPassword.mockResolvedValue(false);
|
|
1283
|
-
mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
|
|
1284
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1285
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1286
|
-
|
|
1287
|
-
try {
|
|
1288
|
-
await service.login(loginDto);
|
|
1289
|
-
fail('Should have thrown NAuthException');
|
|
1290
|
-
} catch (error: any) {
|
|
1291
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
1292
|
-
expect(error.code).toBe(AuthErrorCode.INVALID_CREDENTIALS);
|
|
1293
|
-
}
|
|
1294
|
-
expect(mockAccountLockoutStorage.recordFailedAttempt).toHaveBeenCalled();
|
|
1295
|
-
});
|
|
1296
|
-
|
|
1297
|
-
it('should record failed login attempt for invalid credentials', async () => {
|
|
1298
|
-
const queryBuilder = {
|
|
1299
|
-
where: jest.fn().mockReturnThis(),
|
|
1300
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1301
|
-
select: jest.fn().mockReturnThis(),
|
|
1302
|
-
getOne: jest.fn().mockResolvedValue(mockUser),
|
|
1303
|
-
};
|
|
1304
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1305
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1306
|
-
mockPasswordService.verifyPassword.mockResolvedValue(false);
|
|
1307
|
-
mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
|
|
1308
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1309
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1310
|
-
|
|
1311
|
-
try {
|
|
1312
|
-
await service.login(loginDto);
|
|
1313
|
-
fail('Should have thrown NAuthException');
|
|
1314
|
-
} catch (error: any) {
|
|
1315
|
-
// Expected
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
expect(mockLoginAttemptRepository.create).toHaveBeenCalled();
|
|
1319
|
-
expect(mockLoginAttemptRepository.save).toHaveBeenCalled();
|
|
1320
|
-
});
|
|
1321
|
-
|
|
1322
|
-
it('should record LOGIN_FAILED audit event for invalid credentials', async () => {
|
|
1323
|
-
const queryBuilder = {
|
|
1324
|
-
where: jest.fn().mockReturnThis(),
|
|
1325
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1326
|
-
select: jest.fn().mockReturnThis(),
|
|
1327
|
-
getOne: jest.fn().mockResolvedValue(mockUser),
|
|
1328
|
-
};
|
|
1329
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1330
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1331
|
-
mockPasswordService.verifyPassword.mockResolvedValue(false);
|
|
1332
|
-
mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
|
|
1333
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1334
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1335
|
-
|
|
1336
|
-
try {
|
|
1337
|
-
await service.login(loginDto);
|
|
1338
|
-
fail('Should have thrown NAuthException');
|
|
1339
|
-
} catch (error: any) {
|
|
1340
|
-
// Expected
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
1344
|
-
(expect as any).objectContaining({
|
|
1345
|
-
userId: mockUser.id,
|
|
1346
|
-
eventType: AuthAuditEventType.LOGIN_FAILED,
|
|
1347
|
-
eventStatus: 'FAILURE',
|
|
1348
|
-
reason: 'invalid_credentials',
|
|
1349
|
-
}),
|
|
1350
|
-
);
|
|
1351
|
-
});
|
|
1352
|
-
|
|
1353
|
-
it('should provide helpful error for social-only users', async () => {
|
|
1354
|
-
const socialUser = { ...mockUser, passwordHash: null, socialProviders: ['google'] };
|
|
1355
|
-
const queryBuilder = {
|
|
1356
|
-
where: jest.fn().mockReturnThis(),
|
|
1357
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1358
|
-
select: jest.fn().mockReturnThis(),
|
|
1359
|
-
getOne: jest.fn().mockResolvedValue(socialUser),
|
|
1360
|
-
};
|
|
1361
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1362
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1363
|
-
mockPasswordService.verifyPassword.mockResolvedValue(false);
|
|
1364
|
-
mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
|
|
1365
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1366
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1367
|
-
|
|
1368
|
-
try {
|
|
1369
|
-
await service.login(loginDto);
|
|
1370
|
-
fail('Should have thrown NAuthException');
|
|
1371
|
-
} catch (error: any) {
|
|
1372
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
1373
|
-
expect(error.code).toBe(AuthErrorCode.INVALID_CREDENTIALS);
|
|
1374
|
-
expect(error.message).toContain('Google');
|
|
1375
|
-
}
|
|
1376
|
-
});
|
|
1377
|
-
|
|
1378
|
-
it('should execute afterLoginFailed hook on failed login', async () => {
|
|
1379
|
-
mockConfig.hooks = {
|
|
1380
|
-
afterLoginFailed: jest.fn().mockResolvedValue(undefined),
|
|
1381
|
-
};
|
|
1382
|
-
const queryBuilder = {
|
|
1383
|
-
where: jest.fn().mockReturnThis(),
|
|
1384
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1385
|
-
select: jest.fn().mockReturnThis(),
|
|
1386
|
-
getOne: jest.fn().mockResolvedValue(mockUser),
|
|
1387
|
-
};
|
|
1388
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1389
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1390
|
-
mockPasswordService.verifyPassword.mockResolvedValue(false);
|
|
1391
|
-
mockAccountLockoutStorage.recordFailedAttempt.mockResolvedValue(1);
|
|
1392
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1393
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1394
|
-
|
|
1395
|
-
try {
|
|
1396
|
-
await service.login(loginDto);
|
|
1397
|
-
fail('Should have thrown NAuthException');
|
|
1398
|
-
} catch (error: any) {
|
|
1399
|
-
// Expected
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
expect(mockConfig.hooks!.afterLoginFailed).toHaveBeenCalledWith(loginDto.identifier, 'invalid_credentials');
|
|
1403
|
-
});
|
|
1404
|
-
});
|
|
1405
|
-
|
|
1406
|
-
describe('Account status checks', () => {
|
|
1407
|
-
it('should throw NAuthException if account is inactive', async () => {
|
|
1408
|
-
const inactiveUser = { ...mockUser, isActive: false };
|
|
1409
|
-
const queryBuilder = {
|
|
1410
|
-
where: jest.fn().mockReturnThis(),
|
|
1411
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1412
|
-
select: jest.fn().mockReturnThis(),
|
|
1413
|
-
getOne: jest.fn().mockResolvedValue(inactiveUser),
|
|
1414
|
-
};
|
|
1415
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1416
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1417
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
1418
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1419
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1420
|
-
|
|
1421
|
-
try {
|
|
1422
|
-
await service.login(loginDto);
|
|
1423
|
-
fail('Should have thrown NAuthException');
|
|
1424
|
-
} catch (error: any) {
|
|
1425
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
1426
|
-
expect(error.code).toBe(AuthErrorCode.ACCOUNT_INACTIVE);
|
|
1427
|
-
}
|
|
1428
|
-
});
|
|
1429
|
-
|
|
1430
|
-
it('should record LOGIN_BLOCKED audit event when account is inactive', async () => {
|
|
1431
|
-
const inactiveUser = { ...mockUser, isActive: false };
|
|
1432
|
-
const queryBuilder = {
|
|
1433
|
-
where: jest.fn().mockReturnThis(),
|
|
1434
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1435
|
-
select: jest.fn().mockReturnThis(),
|
|
1436
|
-
getOne: jest.fn().mockResolvedValue(inactiveUser),
|
|
1437
|
-
};
|
|
1438
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1439
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1440
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
1441
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1442
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1443
|
-
|
|
1444
|
-
try {
|
|
1445
|
-
await service.login(loginDto);
|
|
1446
|
-
fail('Should have thrown NAuthException');
|
|
1447
|
-
} catch (error: any) {
|
|
1448
|
-
// Expected
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
1452
|
-
(expect as any).objectContaining({
|
|
1453
|
-
eventType: AuthAuditEventType.LOGIN_BLOCKED,
|
|
1454
|
-
eventStatus: 'FAILURE',
|
|
1455
|
-
reason: 'account_inactive',
|
|
1456
|
-
}),
|
|
1457
|
-
);
|
|
1458
|
-
});
|
|
1459
|
-
});
|
|
1460
|
-
|
|
1461
|
-
describe('Lifecycle hooks', () => {
|
|
1462
|
-
it('should execute beforeLogin hook and reject if returns false', async () => {
|
|
1463
|
-
mockConfig.hooks = {
|
|
1464
|
-
beforeLogin: jest.fn().mockResolvedValue(false),
|
|
1465
|
-
};
|
|
1466
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1467
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1468
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1469
|
-
|
|
1470
|
-
try {
|
|
1471
|
-
await service.login(loginDto);
|
|
1472
|
-
fail('Should have thrown NAuthException');
|
|
1473
|
-
} catch (error: any) {
|
|
1474
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
1475
|
-
expect(error.code).toBe(AuthErrorCode.FORBIDDEN);
|
|
1476
|
-
expect(mockConfig.hooks!.beforeLogin).toHaveBeenCalledWith(loginDto.identifier);
|
|
1477
|
-
}
|
|
1478
|
-
});
|
|
1479
|
-
|
|
1480
|
-
it('should allow login if beforeLogin hook returns true', async () => {
|
|
1481
|
-
mockConfig.hooks = {
|
|
1482
|
-
beforeLogin: jest.fn().mockResolvedValue(true),
|
|
1483
|
-
};
|
|
1484
|
-
const queryBuilder = {
|
|
1485
|
-
where: jest.fn().mockReturnThis(),
|
|
1486
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1487
|
-
select: jest.fn().mockReturnThis(),
|
|
1488
|
-
getOne: jest.fn().mockResolvedValue(mockUser),
|
|
1489
|
-
};
|
|
1490
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1491
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1492
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
1493
|
-
mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
|
|
1494
|
-
mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
|
|
1495
|
-
mockJwtService.generateTokenPair.mockResolvedValue({
|
|
1496
|
-
accessToken: 'access-token',
|
|
1497
|
-
refreshToken: 'refresh-token',
|
|
1498
|
-
expiresIn: 900,
|
|
1499
|
-
});
|
|
1500
|
-
mockJwtService.hashToken.mockReturnValue('token-hash');
|
|
1501
|
-
mockJwtService.validateAccessToken.mockResolvedValue({
|
|
1502
|
-
valid: true,
|
|
1503
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 900 },
|
|
1504
|
-
} as any);
|
|
1505
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
1506
|
-
valid: true,
|
|
1507
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
|
|
1508
|
-
} as any);
|
|
1509
|
-
mockSessionService.createSessionAtomic.mockResolvedValue({
|
|
1510
|
-
session: mockSession,
|
|
1511
|
-
extra: {
|
|
1512
|
-
accessToken: 'access-token',
|
|
1513
|
-
refreshToken: 'refresh-token',
|
|
1514
|
-
},
|
|
1515
|
-
} as any);
|
|
1516
|
-
mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
|
|
1517
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1518
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1519
|
-
|
|
1520
|
-
await service.login(loginDto);
|
|
1521
|
-
|
|
1522
|
-
expect(mockConfig.hooks!.beforeLogin).toHaveBeenCalled();
|
|
1523
|
-
expect(mockSessionService.createSessionAtomic).toHaveBeenCalled();
|
|
1524
|
-
});
|
|
1525
|
-
});
|
|
1526
|
-
|
|
1527
|
-
describe('Challenge system', () => {
|
|
1528
|
-
it('should return VERIFY_EMAIL challenge when email not verified', async () => {
|
|
1529
|
-
const unverifiedUser = { ...mockUser, isEmailVerified: false };
|
|
1530
|
-
const queryBuilder = {
|
|
1531
|
-
where: jest.fn().mockReturnThis(),
|
|
1532
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1533
|
-
select: jest.fn().mockReturnThis(),
|
|
1534
|
-
getOne: jest.fn().mockResolvedValue(unverifiedUser),
|
|
1535
|
-
};
|
|
1536
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1537
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1538
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
1539
|
-
mockChallengeHelper.determineAuthResponse.mockResolvedValue({
|
|
1540
|
-
challengeName: AuthChallenge.VERIFY_EMAIL,
|
|
1541
|
-
session: 'challenge-session',
|
|
1542
|
-
challengeParameters: {
|
|
1543
|
-
email: unverifiedUser.email,
|
|
1544
|
-
},
|
|
1545
|
-
});
|
|
1546
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1547
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1548
|
-
|
|
1549
|
-
const result = await service.login(loginDto);
|
|
1550
|
-
|
|
1551
|
-
expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
|
|
1552
|
-
expect(result.accessToken).toBeUndefined();
|
|
1553
|
-
});
|
|
1554
|
-
|
|
1555
|
-
it('should return FORCE_CHANGE_PASSWORD challenge when mustChangePassword is true', async () => {
|
|
1556
|
-
const userWithMustChange = { ...mockUser, mustChangePassword: true };
|
|
1557
|
-
const queryBuilder = {
|
|
1558
|
-
where: jest.fn().mockReturnThis(),
|
|
1559
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1560
|
-
select: jest.fn().mockReturnThis(),
|
|
1561
|
-
getOne: jest.fn().mockResolvedValue(userWithMustChange),
|
|
1562
|
-
};
|
|
1563
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1564
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1565
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
1566
|
-
mockChallengeHelper.determineAuthResponse.mockResolvedValue({
|
|
1567
|
-
challengeName: AuthChallenge.FORCE_CHANGE_PASSWORD,
|
|
1568
|
-
session: 'challenge-session',
|
|
1569
|
-
challengeParameters: {
|
|
1570
|
-
instructions: 'You must change your password',
|
|
1571
|
-
},
|
|
1572
|
-
});
|
|
1573
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1574
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1575
|
-
|
|
1576
|
-
const result = await service.login(loginDto);
|
|
1577
|
-
|
|
1578
|
-
expect(result.challengeName).toBe(AuthChallenge.FORCE_CHANGE_PASSWORD);
|
|
1579
|
-
});
|
|
1580
|
-
|
|
1581
|
-
it('should return MFA_REQUIRED challenge when MFA is required', async () => {
|
|
1582
|
-
const queryBuilder = {
|
|
1583
|
-
where: jest.fn().mockReturnThis(),
|
|
1584
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1585
|
-
select: jest.fn().mockReturnThis(),
|
|
1586
|
-
getOne: jest.fn().mockResolvedValue(mockUser),
|
|
1587
|
-
};
|
|
1588
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1589
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1590
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
1591
|
-
mockChallengeHelper.determineAuthResponse.mockResolvedValue({
|
|
1592
|
-
challengeName: AuthChallenge.MFA_REQUIRED,
|
|
1593
|
-
session: 'mfa-session',
|
|
1594
|
-
challengeParameters: {},
|
|
1595
|
-
});
|
|
1596
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1597
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1598
|
-
|
|
1599
|
-
const result = await service.login(loginDto);
|
|
1600
|
-
|
|
1601
|
-
expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
|
|
1602
|
-
});
|
|
1603
|
-
|
|
1604
|
-
it('should handle password expiry and force password change', async () => {
|
|
1605
|
-
mockConfig.password!.expiryDays = 90;
|
|
1606
|
-
const userWithExpiredPassword = {
|
|
1607
|
-
...mockUser,
|
|
1608
|
-
passwordChangedAt: new Date(Date.now() - 91 * 24 * 60 * 60 * 1000), // 91 days ago
|
|
1609
|
-
};
|
|
1610
|
-
const queryBuilder = {
|
|
1611
|
-
where: jest.fn().mockReturnThis(),
|
|
1612
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1613
|
-
select: jest.fn().mockReturnThis(),
|
|
1614
|
-
getOne: jest.fn().mockResolvedValue(userWithExpiredPassword),
|
|
1615
|
-
};
|
|
1616
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1617
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1618
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
1619
|
-
mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
|
|
1620
|
-
mockChallengeHelper.determineAuthResponse.mockResolvedValue({
|
|
1621
|
-
challengeName: AuthChallenge.FORCE_CHANGE_PASSWORD,
|
|
1622
|
-
session: 'challenge-session',
|
|
1623
|
-
challengeParameters: {},
|
|
1624
|
-
});
|
|
1625
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1626
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1627
|
-
|
|
1628
|
-
const result = await service.login(loginDto);
|
|
1629
|
-
|
|
1630
|
-
expect(result.challengeName).toBe(AuthChallenge.FORCE_CHANGE_PASSWORD);
|
|
1631
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
1632
|
-
userWithExpiredPassword.id,
|
|
1633
|
-
(expect as any).objectContaining({
|
|
1634
|
-
mustChangePassword: true,
|
|
1635
|
-
}),
|
|
1636
|
-
);
|
|
1637
|
-
});
|
|
1638
|
-
});
|
|
1639
|
-
|
|
1640
|
-
describe('Single session mode', () => {
|
|
1641
|
-
it('should revoke other sessions when disallowMultipleSessions is enabled', async () => {
|
|
1642
|
-
mockConfig.session!.disallowMultipleSessions = true;
|
|
1643
|
-
const queryBuilder = {
|
|
1644
|
-
where: jest.fn().mockReturnThis(),
|
|
1645
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1646
|
-
select: jest.fn().mockReturnThis(),
|
|
1647
|
-
getOne: jest.fn().mockResolvedValue(mockUser),
|
|
1648
|
-
};
|
|
1649
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1650
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1651
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
1652
|
-
mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
|
|
1653
|
-
mockSessionService.revokeAllUserSessions.mockResolvedValue(2);
|
|
1654
|
-
mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
|
|
1655
|
-
mockJwtService.generateTokenPair.mockResolvedValue({
|
|
1656
|
-
accessToken: 'access-token',
|
|
1657
|
-
refreshToken: 'refresh-token',
|
|
1658
|
-
expiresIn: 900,
|
|
1659
|
-
});
|
|
1660
|
-
mockJwtService.hashToken.mockReturnValue('token-hash');
|
|
1661
|
-
mockJwtService.validateAccessToken.mockResolvedValue({
|
|
1662
|
-
valid: true,
|
|
1663
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 900 },
|
|
1664
|
-
} as any);
|
|
1665
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
1666
|
-
valid: true,
|
|
1667
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
|
|
1668
|
-
} as any);
|
|
1669
|
-
mockSessionService.createSessionAtomic.mockResolvedValue({
|
|
1670
|
-
session: mockSession,
|
|
1671
|
-
extra: {
|
|
1672
|
-
accessToken: 'access-token',
|
|
1673
|
-
refreshToken: 'refresh-token',
|
|
1674
|
-
},
|
|
1675
|
-
} as any);
|
|
1676
|
-
mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
|
|
1677
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1678
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1679
|
-
|
|
1680
|
-
await service.login(loginDto);
|
|
1681
|
-
|
|
1682
|
-
expect(mockSessionService.revokeAllUserSessions).toHaveBeenCalledWith(mockUser.id, 'Login from new session');
|
|
1683
|
-
});
|
|
1684
|
-
});
|
|
1685
|
-
|
|
1686
|
-
describe('Trusted device management', () => {
|
|
1687
|
-
it('should check if device is already trusted', async () => {
|
|
1688
|
-
mockConfig.mfa = {
|
|
1689
|
-
rememberDevices: 'always',
|
|
1690
|
-
rememberDeviceDays: 30,
|
|
1691
|
-
};
|
|
1692
|
-
mockClientInfo.deviceToken = 'existing-device-token';
|
|
1693
|
-
mockTrustedDeviceService.isDeviceTrusted.mockResolvedValue(true);
|
|
1694
|
-
const queryBuilder = {
|
|
1695
|
-
where: jest.fn().mockReturnThis(),
|
|
1696
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1697
|
-
select: jest.fn().mockReturnThis(),
|
|
1698
|
-
getOne: jest.fn().mockResolvedValue(mockUser),
|
|
1699
|
-
};
|
|
1700
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1701
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1702
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
1703
|
-
mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
|
|
1704
|
-
mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
|
|
1705
|
-
mockJwtService.generateTokenPair.mockResolvedValue({
|
|
1706
|
-
accessToken: 'access-token',
|
|
1707
|
-
refreshToken: 'refresh-token',
|
|
1708
|
-
expiresIn: 900,
|
|
1709
|
-
});
|
|
1710
|
-
mockJwtService.hashToken.mockReturnValue('token-hash');
|
|
1711
|
-
mockJwtService.validateAccessToken.mockResolvedValue({
|
|
1712
|
-
valid: true,
|
|
1713
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 900 },
|
|
1714
|
-
} as any);
|
|
1715
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
1716
|
-
valid: true,
|
|
1717
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
|
|
1718
|
-
} as any);
|
|
1719
|
-
mockSessionService.createSessionAtomic.mockResolvedValue({
|
|
1720
|
-
session: mockSession,
|
|
1721
|
-
extra: {
|
|
1722
|
-
accessToken: 'access-token',
|
|
1723
|
-
refreshToken: 'refresh-token',
|
|
1724
|
-
},
|
|
1725
|
-
} as any);
|
|
1726
|
-
mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
|
|
1727
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1728
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1729
|
-
|
|
1730
|
-
const result = await service.login(loginDto);
|
|
1731
|
-
|
|
1732
|
-
expect(mockTrustedDeviceService.isDeviceTrusted).toHaveBeenCalledWith('existing-device-token', mockUser.id);
|
|
1733
|
-
expect(result.trusted).toBe(true);
|
|
1734
|
-
});
|
|
1735
|
-
|
|
1736
|
-
it('should auto-create trusted device when rememberDevices is always', async () => {
|
|
1737
|
-
mockConfig.mfa = {
|
|
1738
|
-
rememberDevices: 'always',
|
|
1739
|
-
rememberDeviceDays: 30,
|
|
1740
|
-
};
|
|
1741
|
-
mockTrustedDeviceService.isDeviceTrusted.mockResolvedValue(false);
|
|
1742
|
-
mockTrustedDeviceService.createTrustedDevice.mockResolvedValue('new-device-token');
|
|
1743
|
-
const queryBuilder = {
|
|
1744
|
-
where: jest.fn().mockReturnThis(),
|
|
1745
|
-
orWhere: jest.fn().mockReturnThis(),
|
|
1746
|
-
select: jest.fn().mockReturnThis(),
|
|
1747
|
-
getOne: jest.fn().mockResolvedValue(mockUser),
|
|
1748
|
-
};
|
|
1749
|
-
mockUserRepository.createQueryBuilder.mockReturnValue(queryBuilder as any);
|
|
1750
|
-
mockAccountLockoutStorage.isAccountLocked.mockResolvedValue(false);
|
|
1751
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
1752
|
-
mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
|
|
1753
|
-
mockJwtService.generateTokenFamily.mockReturnValue('family-abc');
|
|
1754
|
-
mockJwtService.generateTokenPair.mockResolvedValue({
|
|
1755
|
-
accessToken: 'access-token',
|
|
1756
|
-
refreshToken: 'refresh-token',
|
|
1757
|
-
expiresIn: 900,
|
|
1758
|
-
});
|
|
1759
|
-
mockJwtService.hashToken.mockReturnValue('token-hash');
|
|
1760
|
-
mockJwtService.validateAccessToken.mockResolvedValue({
|
|
1761
|
-
valid: true,
|
|
1762
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 900 },
|
|
1763
|
-
} as any);
|
|
1764
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
1765
|
-
valid: true,
|
|
1766
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 604800 },
|
|
1767
|
-
} as any);
|
|
1768
|
-
mockSessionService.createSessionAtomic.mockResolvedValue({
|
|
1769
|
-
session: mockSession,
|
|
1770
|
-
extra: {
|
|
1771
|
-
accessToken: 'access-token',
|
|
1772
|
-
refreshToken: 'refresh-token',
|
|
1773
|
-
},
|
|
1774
|
-
} as any);
|
|
1775
|
-
mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
|
|
1776
|
-
mockLoginAttemptRepository.create.mockReturnValue({} as any);
|
|
1777
|
-
mockLoginAttemptRepository.save.mockResolvedValue({} as any);
|
|
1778
|
-
|
|
1779
|
-
const result = await service.login(loginDto);
|
|
1780
|
-
|
|
1781
|
-
expect(mockTrustedDeviceService.createTrustedDevice).toHaveBeenCalled();
|
|
1782
|
-
expect(result.deviceToken).toBe('new-device-token');
|
|
1783
|
-
expect(result.trusted).toBe(true);
|
|
1784
|
-
});
|
|
1785
|
-
});
|
|
1786
|
-
});
|
|
1787
|
-
|
|
1788
|
-
// ============================================================================
|
|
1789
|
-
// refreshToken Tests
|
|
1790
|
-
// ============================================================================
|
|
1791
|
-
|
|
1792
|
-
describe('refreshToken()', () => {
|
|
1793
|
-
const mockRefreshToken = 'refresh-token-123';
|
|
1794
|
-
const mockTokenHash = 'token-hash-123';
|
|
1795
|
-
const mockPayload = {
|
|
1796
|
-
sub: mockUser.sub,
|
|
1797
|
-
email: mockUser.email,
|
|
1798
|
-
type: 'refresh' as const,
|
|
1799
|
-
sessionId: '1',
|
|
1800
|
-
tokenFamily: 'family-abc',
|
|
1801
|
-
iat: Math.floor(Date.now() / 1000),
|
|
1802
|
-
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
1803
|
-
};
|
|
1804
|
-
|
|
1805
|
-
beforeEach(() => {
|
|
1806
|
-
mockJwtService.hashToken.mockReturnValue(mockTokenHash);
|
|
1807
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
1808
|
-
valid: true,
|
|
1809
|
-
payload: mockPayload,
|
|
1810
|
-
} as any);
|
|
1811
|
-
mockJwtService.validateAccessToken.mockResolvedValue({
|
|
1812
|
-
valid: true,
|
|
1813
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 900 },
|
|
1814
|
-
} as any);
|
|
1815
|
-
mockSessionService.findByRefreshToken.mockResolvedValue(mockSession);
|
|
1816
|
-
mockSessionService.findByIdLight.mockResolvedValue(mockSession);
|
|
1817
|
-
mockSessionService.acquireRefreshLock.mockResolvedValue(true);
|
|
1818
|
-
mockSessionService.isRefreshTokenUsed.mockResolvedValue(false);
|
|
1819
|
-
mockSessionService.markRefreshTokenAsUsed.mockResolvedValue(true);
|
|
1820
|
-
mockJwtService.generateTokenPair.mockResolvedValue({
|
|
1821
|
-
accessToken: 'new-access-token',
|
|
1822
|
-
refreshToken: 'new-refresh-token',
|
|
1823
|
-
expiresIn: 900,
|
|
1824
|
-
});
|
|
1825
|
-
mockJwtService.decodeToken.mockReturnValue(mockPayload as any);
|
|
1826
|
-
mockSessionService.updateTokens.mockResolvedValue(undefined);
|
|
1827
|
-
mockUserRepository.findOne.mockResolvedValue(mockUser as any);
|
|
1828
|
-
});
|
|
1829
|
-
|
|
1830
|
-
describe('Successful token refresh', () => {
|
|
1831
|
-
it('should refresh tokens successfully', async () => {
|
|
1832
|
-
const result = await service.refreshToken(mockRefreshToken);
|
|
1833
|
-
|
|
1834
|
-
expect(result.accessToken).toBe('new-access-token');
|
|
1835
|
-
expect(result.refreshToken).toBe('new-refresh-token');
|
|
1836
|
-
expect(mockJwtService.hashToken).toHaveBeenCalledWith(mockRefreshToken);
|
|
1837
|
-
expect(mockSessionService.findByRefreshToken).toHaveBeenCalledWith(mockTokenHash);
|
|
1838
|
-
expect(mockSessionService.acquireRefreshLock).toHaveBeenCalled();
|
|
1839
|
-
expect(mockJwtService.validateRefreshToken).toHaveBeenCalledWith(mockRefreshToken);
|
|
1840
|
-
expect(mockSessionService.updateTokens).toHaveBeenCalled();
|
|
1841
|
-
});
|
|
1842
|
-
|
|
1843
|
-
it('should acquire distributed lock before validation', async () => {
|
|
1844
|
-
await service.refreshToken(mockRefreshToken);
|
|
1845
|
-
|
|
1846
|
-
const lockCall = mockSessionService.acquireRefreshLock.mock.calls[0];
|
|
1847
|
-
expect(lockCall[0]).toContain('session-refresh:');
|
|
1848
|
-
expect(lockCall[0]).toContain(mockSession.id.toString());
|
|
1849
|
-
});
|
|
1850
|
-
|
|
1851
|
-
it('should mark refresh token as used when reuseDetection is enabled', async () => {
|
|
1852
|
-
mockConfig.jwt.refreshToken.reuseDetection = true;
|
|
1853
|
-
|
|
1854
|
-
await service.refreshToken(mockRefreshToken);
|
|
1855
|
-
|
|
1856
|
-
expect(mockSessionService.markRefreshTokenAsUsed).toHaveBeenCalledWith(
|
|
1857
|
-
mockTokenHash,
|
|
1858
|
-
mockJwtService.getRefreshTokenTTL(),
|
|
1859
|
-
);
|
|
1860
|
-
});
|
|
1861
|
-
|
|
1862
|
-
it('should not mark token as used when reuseDetection is disabled', async () => {
|
|
1863
|
-
mockConfig.jwt.refreshToken.reuseDetection = false;
|
|
1864
|
-
|
|
1865
|
-
await service.refreshToken(mockRefreshToken);
|
|
1866
|
-
|
|
1867
|
-
expect(mockSessionService.markRefreshTokenAsUsed).not.toHaveBeenCalled();
|
|
1868
|
-
});
|
|
1869
|
-
|
|
1870
|
-
it('should rotate refresh token (generate new token pair)', async () => {
|
|
1871
|
-
await service.refreshToken(mockRefreshToken);
|
|
1872
|
-
|
|
1873
|
-
expect(mockJwtService.generateTokenPair).toHaveBeenCalledWith({
|
|
1874
|
-
userId: mockUser.sub,
|
|
1875
|
-
email: mockUser.email,
|
|
1876
|
-
sessionId: mockSession.id.toString(),
|
|
1877
|
-
tokenFamily: mockSession.tokenFamily,
|
|
1878
|
-
});
|
|
1879
|
-
});
|
|
1880
|
-
|
|
1881
|
-
it('should update session with new token hashes', async () => {
|
|
1882
|
-
mockJwtService.hashToken
|
|
1883
|
-
.mockReturnValueOnce(mockTokenHash) // For initial hash
|
|
1884
|
-
.mockReturnValueOnce('new-access-hash')
|
|
1885
|
-
.mockReturnValueOnce('new-refresh-hash');
|
|
1886
|
-
|
|
1887
|
-
await service.refreshToken(mockRefreshToken);
|
|
1888
|
-
|
|
1889
|
-
expect(mockSessionService.updateTokens).toHaveBeenCalledWith(
|
|
1890
|
-
mockSession.id,
|
|
1891
|
-
'new-access-hash',
|
|
1892
|
-
'new-refresh-hash',
|
|
1893
|
-
);
|
|
1894
|
-
});
|
|
1895
|
-
|
|
1896
|
-
it('should return token expiry times', async () => {
|
|
1897
|
-
const accessExp = Math.floor(Date.now() / 1000) + 900;
|
|
1898
|
-
const refreshExp = Math.floor(Date.now() / 1000) + 604800;
|
|
1899
|
-
mockJwtService.validateAccessToken.mockResolvedValue({
|
|
1900
|
-
valid: true,
|
|
1901
|
-
payload: { exp: accessExp },
|
|
1902
|
-
} as any);
|
|
1903
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
1904
|
-
valid: true,
|
|
1905
|
-
payload: { exp: refreshExp },
|
|
1906
|
-
} as any);
|
|
1907
|
-
|
|
1908
|
-
const result = await service.refreshToken(mockRefreshToken);
|
|
1909
|
-
|
|
1910
|
-
expect(result.accessTokenExpiresAt).toBe(accessExp);
|
|
1911
|
-
expect(result.refreshTokenExpiresAt).toBe(refreshExp);
|
|
1912
|
-
});
|
|
1913
|
-
|
|
1914
|
-
it('should release lock after successful refresh', async () => {
|
|
1915
|
-
await service.refreshToken(mockRefreshToken);
|
|
1916
|
-
|
|
1917
|
-
expect(mockSessionService.releaseRefreshLock).toHaveBeenCalled();
|
|
1918
|
-
});
|
|
1919
|
-
});
|
|
1920
|
-
|
|
1921
|
-
describe('Invalid token handling', () => {
|
|
1922
|
-
it('should throw NAuthException if refresh token is invalid', async () => {
|
|
1923
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
1924
|
-
valid: false,
|
|
1925
|
-
payload: undefined,
|
|
1926
|
-
} as any);
|
|
1927
|
-
|
|
1928
|
-
try {
|
|
1929
|
-
await service.refreshToken('invalid-token');
|
|
1930
|
-
fail('Should have thrown NAuthException');
|
|
1931
|
-
} catch (error: any) {
|
|
1932
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
1933
|
-
expect(error.code).toBe(AuthErrorCode.TOKEN_INVALID);
|
|
1934
|
-
}
|
|
1935
|
-
});
|
|
1936
|
-
|
|
1937
|
-
it('should throw NAuthException if session not found', async () => {
|
|
1938
|
-
mockSessionService.findByRefreshToken.mockResolvedValue(null);
|
|
1939
|
-
|
|
1940
|
-
try {
|
|
1941
|
-
await service.refreshToken(mockRefreshToken);
|
|
1942
|
-
fail('Should have thrown NAuthException');
|
|
1943
|
-
} catch (error: any) {
|
|
1944
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
1945
|
-
expect(error.code).toBe(AuthErrorCode.SESSION_NOT_FOUND);
|
|
1946
|
-
}
|
|
1947
|
-
});
|
|
1948
|
-
|
|
1949
|
-
it('should throw NAuthException if session is revoked', async () => {
|
|
1950
|
-
const revokedSession = { ...mockSession, isRevoked: true };
|
|
1951
|
-
mockSessionService.findByRefreshToken.mockResolvedValue(revokedSession);
|
|
1952
|
-
|
|
1953
|
-
try {
|
|
1954
|
-
await service.refreshToken(mockRefreshToken);
|
|
1955
|
-
fail('Should have thrown NAuthException');
|
|
1956
|
-
} catch (error: any) {
|
|
1957
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
1958
|
-
expect(error.code).toBe(AuthErrorCode.SESSION_NOT_FOUND);
|
|
1959
|
-
}
|
|
1960
|
-
});
|
|
1961
|
-
|
|
1962
|
-
it('should throw NAuthException if session changed after lock acquisition', async () => {
|
|
1963
|
-
mockSessionService.findByIdLight.mockResolvedValue(null);
|
|
1964
|
-
|
|
1965
|
-
try {
|
|
1966
|
-
await service.refreshToken(mockRefreshToken);
|
|
1967
|
-
fail('Should have thrown NAuthException');
|
|
1968
|
-
} catch (error: any) {
|
|
1969
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
1970
|
-
expect(error.code).toBe(AuthErrorCode.SESSION_NOT_FOUND);
|
|
1971
|
-
}
|
|
1972
|
-
});
|
|
1973
|
-
});
|
|
1974
|
-
|
|
1975
|
-
describe('Distributed locking', () => {
|
|
1976
|
-
it('should throw NAuthException if lock cannot be acquired', async () => {
|
|
1977
|
-
mockSessionService.acquireRefreshLock.mockResolvedValue(false);
|
|
1978
|
-
|
|
1979
|
-
try {
|
|
1980
|
-
await service.refreshToken(mockRefreshToken);
|
|
1981
|
-
fail('Should have thrown NAuthException');
|
|
1982
|
-
} catch (error: any) {
|
|
1983
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
1984
|
-
expect(error.code).toBe(AuthErrorCode.RATE_LIMIT_LOGIN);
|
|
1985
|
-
}
|
|
1986
|
-
});
|
|
1987
|
-
|
|
1988
|
-
it('should release lock even if validation fails', async () => {
|
|
1989
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
1990
|
-
valid: false,
|
|
1991
|
-
payload: undefined,
|
|
1992
|
-
} as any);
|
|
1993
|
-
|
|
1994
|
-
try {
|
|
1995
|
-
await service.refreshToken(mockRefreshToken);
|
|
1996
|
-
fail('Should have thrown NAuthException');
|
|
1997
|
-
} catch (error: any) {
|
|
1998
|
-
// Expected
|
|
1999
|
-
}
|
|
2000
|
-
|
|
2001
|
-
expect(mockSessionService.releaseRefreshLock).toHaveBeenCalled();
|
|
2002
|
-
});
|
|
2003
|
-
|
|
2004
|
-
it('should release lock even if token generation fails', async () => {
|
|
2005
|
-
mockJwtService.generateTokenPair.mockRejectedValue(new Error('Token generation failed'));
|
|
2006
|
-
|
|
2007
|
-
try {
|
|
2008
|
-
await service.refreshToken(mockRefreshToken);
|
|
2009
|
-
fail('Should have thrown Error');
|
|
2010
|
-
} catch (error: any) {
|
|
2011
|
-
// Expected
|
|
2012
|
-
}
|
|
2013
|
-
|
|
2014
|
-
expect(mockSessionService.releaseRefreshLock).toHaveBeenCalled();
|
|
2015
|
-
});
|
|
2016
|
-
});
|
|
2017
|
-
|
|
2018
|
-
describe('Token reuse detection', () => {
|
|
2019
|
-
it('should detect token reuse via atomic mark failure and audit the event', async () => {
|
|
2020
|
-
mockConfig.jwt.refreshToken.reuseDetection = true;
|
|
2021
|
-
// First check passes (token not yet marked)
|
|
2022
|
-
mockSessionService.isRefreshTokenUsed.mockResolvedValue(false);
|
|
2023
|
-
// But atomic mark fails (token was already used by another request)
|
|
2024
|
-
mockSessionService.markRefreshTokenAsUsed.mockResolvedValue(false);
|
|
2025
|
-
|
|
2026
|
-
try {
|
|
2027
|
-
await service.refreshToken(mockRefreshToken);
|
|
2028
|
-
fail('Should have thrown NAuthException');
|
|
2029
|
-
} catch (error: any) {
|
|
2030
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
2031
|
-
expect(error.code).toBe(AuthErrorCode.TOKEN_INVALID);
|
|
2032
|
-
}
|
|
2033
|
-
|
|
2034
|
-
// Should audit the reuse attempt
|
|
2035
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
2036
|
-
(expect as any).objectContaining({
|
|
2037
|
-
eventType: AuthAuditEventType.SUSPICIOUS_ACTIVITY,
|
|
2038
|
-
riskFactors: (expect as any).arrayContaining([RiskFactor.TOKEN_REUSE_ATTEMPT]),
|
|
2039
|
-
}),
|
|
2040
|
-
);
|
|
2041
|
-
});
|
|
2042
|
-
|
|
2043
|
-
it('should handle cookie race condition (same session, token already used)', async () => {
|
|
2044
|
-
mockConfig.jwt.refreshToken.reuseDetection = true;
|
|
2045
|
-
mockSessionService.isRefreshTokenUsed.mockResolvedValue(true);
|
|
2046
|
-
// Token's sessionId matches the session we found (cookie race)
|
|
2047
|
-
mockJwtService.decodeToken.mockReturnValue({
|
|
2048
|
-
...mockPayload,
|
|
2049
|
-
sessionId: mockSession.id.toString(),
|
|
2050
|
-
} as any);
|
|
2051
|
-
|
|
2052
|
-
const result = await service.refreshToken(mockRefreshToken);
|
|
2053
|
-
|
|
2054
|
-
// Should return current tokens (not throw error)
|
|
2055
|
-
expect(result.accessToken).toBeDefined();
|
|
2056
|
-
expect(result.refreshToken).toBeDefined();
|
|
2057
|
-
});
|
|
2058
|
-
|
|
2059
|
-
it('should detect attack when token reused from different session', async () => {
|
|
2060
|
-
mockConfig.jwt.refreshToken.reuseDetection = true;
|
|
2061
|
-
mockSessionService.isRefreshTokenUsed.mockResolvedValue(true);
|
|
2062
|
-
// Token's sessionId doesn't match the session we found (attack!)
|
|
2063
|
-
mockJwtService.decodeToken.mockReturnValue({
|
|
2064
|
-
...mockPayload,
|
|
2065
|
-
sessionId: '999', // Different session ID
|
|
2066
|
-
} as any);
|
|
2067
|
-
mockSessionService.revokeSession.mockResolvedValue(undefined);
|
|
2068
|
-
|
|
2069
|
-
try {
|
|
2070
|
-
await service.refreshToken(mockRefreshToken);
|
|
2071
|
-
fail('Should have thrown NAuthException');
|
|
2072
|
-
} catch (error: any) {
|
|
2073
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
2074
|
-
expect(error.code).toBe(AuthErrorCode.TOKEN_INVALID);
|
|
2075
|
-
}
|
|
2076
|
-
|
|
2077
|
-
expect(mockSessionService.revokeSession).toHaveBeenCalledWith(
|
|
2078
|
-
mockSession.id,
|
|
2079
|
-
'Token reuse detected - possible token theft',
|
|
2080
|
-
);
|
|
2081
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
2082
|
-
(expect as any).objectContaining({
|
|
2083
|
-
eventType: AuthAuditEventType.SUSPICIOUS_ACTIVITY,
|
|
2084
|
-
eventStatus: 'SUSPICIOUS',
|
|
2085
|
-
riskFactors: (expect as any).arrayContaining([RiskFactor.TOKEN_THEFT_ATTEMPT]),
|
|
2086
|
-
}),
|
|
2087
|
-
);
|
|
2088
|
-
});
|
|
2089
|
-
|
|
2090
|
-
it('should throw NAuthException if atomic mark fails (reuse detected)', async () => {
|
|
2091
|
-
mockConfig.jwt.refreshToken.reuseDetection = true;
|
|
2092
|
-
mockSessionService.markRefreshTokenAsUsed.mockResolvedValue(false);
|
|
2093
|
-
|
|
2094
|
-
try {
|
|
2095
|
-
await service.refreshToken(mockRefreshToken);
|
|
2096
|
-
fail('Should have thrown NAuthException');
|
|
2097
|
-
} catch (error: any) {
|
|
2098
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
2099
|
-
expect(error.code).toBe(AuthErrorCode.TOKEN_INVALID);
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
2103
|
-
(expect as any).objectContaining({
|
|
2104
|
-
eventType: AuthAuditEventType.SUSPICIOUS_ACTIVITY,
|
|
2105
|
-
riskFactors: (expect as any).arrayContaining([RiskFactor.TOKEN_REUSE_ATTEMPT]),
|
|
2106
|
-
}),
|
|
2107
|
-
);
|
|
2108
|
-
});
|
|
2109
|
-
});
|
|
2110
|
-
|
|
2111
|
-
describe('Token family management', () => {
|
|
2112
|
-
it('should use same token family for rotated tokens', async () => {
|
|
2113
|
-
await service.refreshToken(mockRefreshToken);
|
|
2114
|
-
|
|
2115
|
-
expect(mockJwtService.generateTokenPair).toHaveBeenCalledWith(
|
|
2116
|
-
(expect as any).objectContaining({
|
|
2117
|
-
tokenFamily: mockSession.tokenFamily,
|
|
2118
|
-
}),
|
|
2119
|
-
);
|
|
2120
|
-
});
|
|
2121
|
-
|
|
2122
|
-
it('should audit token reuse attempt when atomic mark fails', async () => {
|
|
2123
|
-
mockConfig.jwt.refreshToken.reuseDetection = true;
|
|
2124
|
-
mockSessionService.isRefreshTokenUsed.mockResolvedValue(false);
|
|
2125
|
-
mockSessionService.markRefreshTokenAsUsed.mockResolvedValue(false);
|
|
2126
|
-
|
|
2127
|
-
try {
|
|
2128
|
-
await service.refreshToken(mockRefreshToken);
|
|
2129
|
-
fail('Should have thrown NAuthException');
|
|
2130
|
-
} catch (error: any) {
|
|
2131
|
-
// Expected
|
|
2132
|
-
}
|
|
2133
|
-
|
|
2134
|
-
// Should audit the reuse attempt
|
|
2135
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
2136
|
-
(expect as any).objectContaining({
|
|
2137
|
-
eventType: AuthAuditEventType.SUSPICIOUS_ACTIVITY,
|
|
2138
|
-
riskFactors: (expect as any).arrayContaining([RiskFactor.TOKEN_REUSE_ATTEMPT]),
|
|
2139
|
-
}),
|
|
2140
|
-
);
|
|
2141
|
-
});
|
|
2142
|
-
});
|
|
2143
|
-
|
|
2144
|
-
describe('Error handling', () => {
|
|
2145
|
-
it('should handle user not found in cookie race scenario', async () => {
|
|
2146
|
-
// Cookie race scenario - token already used for same session
|
|
2147
|
-
mockConfig.jwt.refreshToken.reuseDetection = true;
|
|
2148
|
-
mockSessionService.isRefreshTokenUsed.mockResolvedValue(true);
|
|
2149
|
-
mockJwtService.decodeToken.mockReturnValue({
|
|
2150
|
-
...mockPayload,
|
|
2151
|
-
sessionId: mockSession.id.toString(),
|
|
2152
|
-
} as any);
|
|
2153
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
2154
|
-
|
|
2155
|
-
try {
|
|
2156
|
-
await service.refreshToken(mockRefreshToken);
|
|
2157
|
-
fail('Should have thrown NAuthException');
|
|
2158
|
-
} catch (error: any) {
|
|
2159
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
2160
|
-
expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
|
|
2161
|
-
}
|
|
2162
|
-
});
|
|
2163
|
-
|
|
2164
|
-
it('should handle audit logging errors gracefully in cookie race scenario', async () => {
|
|
2165
|
-
// Cookie race scenario where audit fails
|
|
2166
|
-
mockConfig.jwt.refreshToken.reuseDetection = true;
|
|
2167
|
-
mockSessionService.isRefreshTokenUsed.mockResolvedValue(true);
|
|
2168
|
-
mockJwtService.decodeToken.mockReturnValue({
|
|
2169
|
-
...mockPayload,
|
|
2170
|
-
sessionId: mockSession.id.toString(),
|
|
2171
|
-
} as any);
|
|
2172
|
-
mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
|
|
2173
|
-
|
|
2174
|
-
const result = await service.refreshToken(mockRefreshToken);
|
|
2175
|
-
|
|
2176
|
-
// Should still succeed despite audit error
|
|
2177
|
-
expect(result.accessToken).toBeDefined();
|
|
2178
|
-
});
|
|
2179
|
-
});
|
|
2180
|
-
|
|
2181
|
-
describe('Edge cases', () => {
|
|
2182
|
-
it('should handle missing expiry in token payload', async () => {
|
|
2183
|
-
mockJwtService.validateAccessToken.mockResolvedValue({
|
|
2184
|
-
valid: true,
|
|
2185
|
-
payload: {},
|
|
2186
|
-
} as any);
|
|
2187
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
2188
|
-
valid: true,
|
|
2189
|
-
payload: {},
|
|
2190
|
-
} as any);
|
|
2191
|
-
|
|
2192
|
-
const result = await service.refreshToken(mockRefreshToken);
|
|
2193
|
-
|
|
2194
|
-
expect(result.accessTokenExpiresAt).toBe(0);
|
|
2195
|
-
expect(result.refreshTokenExpiresAt).toBe(0);
|
|
2196
|
-
});
|
|
2197
|
-
|
|
2198
|
-
it('should handle token validation returning undefined payload', async () => {
|
|
2199
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
2200
|
-
valid: false,
|
|
2201
|
-
payload: undefined,
|
|
2202
|
-
} as any);
|
|
2203
|
-
|
|
2204
|
-
try {
|
|
2205
|
-
await service.refreshToken(mockRefreshToken);
|
|
2206
|
-
fail('Should have thrown NAuthException');
|
|
2207
|
-
} catch (error: any) {
|
|
2208
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
2209
|
-
}
|
|
2210
|
-
});
|
|
2211
|
-
});
|
|
2212
|
-
});
|
|
2213
|
-
|
|
2214
|
-
// ============================================================================
|
|
2215
|
-
// logout Tests
|
|
2216
|
-
// ============================================================================
|
|
2217
|
-
|
|
2218
|
-
describe('logout()', () => {
|
|
2219
|
-
const mockSub = 'user-123';
|
|
2220
|
-
const mockSessionId = '1';
|
|
2221
|
-
|
|
2222
|
-
beforeEach(() => {
|
|
2223
|
-
mockSessionService.revokeSession.mockResolvedValue(undefined);
|
|
2224
|
-
mockSessionService.findById.mockResolvedValue(mockSession);
|
|
2225
|
-
mockUserRepository.findOne.mockResolvedValue(mockUser as any);
|
|
2226
|
-
});
|
|
2227
|
-
|
|
2228
|
-
describe('Successful logout', () => {
|
|
2229
|
-
it('should revoke session on logout', async () => {
|
|
2230
|
-
await service.logout(mockSub, mockSessionId);
|
|
2231
|
-
|
|
2232
|
-
expect(mockSessionService.revokeSession).toHaveBeenCalledWith(mockSessionId, 'User logout', undefined);
|
|
2233
|
-
});
|
|
2234
|
-
|
|
2235
|
-
it('should revoke session with audit metadata', async () => {
|
|
2236
|
-
await service.logout(mockSub, mockSessionId, false);
|
|
2237
|
-
|
|
2238
|
-
expect(mockSessionService.revokeSession).toHaveBeenCalledWith(mockSessionId, 'User logout', undefined);
|
|
2239
|
-
});
|
|
2240
|
-
|
|
2241
|
-
it('should complete logout successfully', async () => {
|
|
2242
|
-
await service.logout(mockSub, mockSessionId);
|
|
2243
|
-
|
|
2244
|
-
expect(mockSessionService.revokeSession).toHaveBeenCalled();
|
|
2245
|
-
});
|
|
2246
|
-
});
|
|
2247
|
-
|
|
2248
|
-
describe('Forget device (forgetMe)', () => {
|
|
2249
|
-
it('should revoke trusted device when forgetMe is true', async () => {
|
|
2250
|
-
mockConfig.mfa = {
|
|
2251
|
-
rememberDevices: 'always',
|
|
2252
|
-
rememberDeviceDays: 30,
|
|
2253
|
-
};
|
|
2254
|
-
mockClientInfo.deviceToken = 'device-token-123';
|
|
2255
|
-
|
|
2256
|
-
await service.logout(mockSub, mockSessionId, true);
|
|
2257
|
-
|
|
2258
|
-
expect(mockSessionService.revokeSession).toHaveBeenCalledWith(
|
|
2259
|
-
mockSessionId,
|
|
2260
|
-
'User logout',
|
|
2261
|
-
(expect as any).objectContaining({
|
|
2262
|
-
deviceForgotten: true,
|
|
2263
|
-
reason: 'User requested device to be forgotten on logout',
|
|
2264
|
-
}),
|
|
2265
|
-
);
|
|
2266
|
-
expect(mockSessionService.findById).toHaveBeenCalledWith(parseInt(mockSessionId, 10));
|
|
2267
|
-
expect(mockTrustedDeviceService.revokeTrustedDevice).toHaveBeenCalledWith(
|
|
2268
|
-
mockClientInfo.deviceToken,
|
|
2269
|
-
mockSession.userId,
|
|
2270
|
-
);
|
|
2271
|
-
});
|
|
2272
|
-
|
|
2273
|
-
it('should not revoke trusted device when forgetMe is false', async () => {
|
|
2274
|
-
await service.logout(mockSub, mockSessionId, false);
|
|
2275
|
-
|
|
2276
|
-
expect(mockTrustedDeviceService.revokeTrustedDevice).not.toHaveBeenCalled();
|
|
2277
|
-
});
|
|
2278
|
-
|
|
2279
|
-
it('should handle missing deviceToken gracefully when forgetMe is true', async () => {
|
|
2280
|
-
mockConfig.mfa = {
|
|
2281
|
-
rememberDevices: 'always',
|
|
2282
|
-
rememberDeviceDays: 30,
|
|
2283
|
-
};
|
|
2284
|
-
mockClientInfo.deviceToken = undefined;
|
|
2285
|
-
|
|
2286
|
-
await service.logout(mockSub, mockSessionId, true);
|
|
2287
|
-
|
|
2288
|
-
// Should still revoke session, but not call revokeTrustedDevice
|
|
2289
|
-
expect(mockSessionService.revokeSession).toHaveBeenCalled();
|
|
2290
|
-
expect(mockTrustedDeviceService.revokeTrustedDevice).not.toHaveBeenCalled();
|
|
2291
|
-
});
|
|
2292
|
-
|
|
2293
|
-
it('should record DEVICE_UNTRUSTED audit event when forgetMe is true', async () => {
|
|
2294
|
-
mockConfig.mfa = {
|
|
2295
|
-
rememberDevices: 'always',
|
|
2296
|
-
rememberDeviceDays: 30,
|
|
2297
|
-
};
|
|
2298
|
-
mockClientInfo.deviceToken = 'device-token-123';
|
|
2299
|
-
|
|
2300
|
-
await service.logout(mockSub, mockSessionId, true);
|
|
2301
|
-
|
|
2302
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
2303
|
-
(expect as any).objectContaining({
|
|
2304
|
-
eventType: AuthAuditEventType.DEVICE_UNTRUSTED,
|
|
2305
|
-
eventStatus: 'SUCCESS',
|
|
2306
|
-
}),
|
|
2307
|
-
);
|
|
2308
|
-
});
|
|
2309
|
-
});
|
|
2310
|
-
|
|
2311
|
-
describe('Error handling', () => {
|
|
2312
|
-
it('should handle session revocation errors gracefully', async () => {
|
|
2313
|
-
mockSessionService.revokeSession.mockRejectedValue(new Error('Session not found'));
|
|
2314
|
-
|
|
2315
|
-
try {
|
|
2316
|
-
await service.logout(mockSub, mockSessionId);
|
|
2317
|
-
fail('Should have thrown Error');
|
|
2318
|
-
} catch (error: any) {
|
|
2319
|
-
expect(error).toBeInstanceOf(Error);
|
|
2320
|
-
}
|
|
2321
|
-
});
|
|
2322
|
-
|
|
2323
|
-
it('should handle trusted device removal errors gracefully', async () => {
|
|
2324
|
-
mockConfig.mfa = {
|
|
2325
|
-
rememberDevices: 'always',
|
|
2326
|
-
rememberDeviceDays: 30,
|
|
2327
|
-
};
|
|
2328
|
-
mockClientInfo.deviceToken = 'device-token-123';
|
|
2329
|
-
(mockTrustedDeviceService.revokeTrustedDevice as jest.Mock).mockRejectedValue(new Error('Device not found'));
|
|
2330
|
-
|
|
2331
|
-
// Should still complete logout even if device removal fails
|
|
2332
|
-
await service.logout(mockSub, mockSessionId, true);
|
|
2333
|
-
|
|
2334
|
-
expect(mockSessionService.revokeSession).toHaveBeenCalled();
|
|
2335
|
-
expect(mockLogger.debug).toHaveBeenCalled();
|
|
2336
|
-
});
|
|
2337
|
-
|
|
2338
|
-
it('should handle audit logging errors gracefully when forgetMe is true', async () => {
|
|
2339
|
-
mockConfig.mfa = {
|
|
2340
|
-
rememberDevices: 'always',
|
|
2341
|
-
rememberDeviceDays: 30,
|
|
2342
|
-
};
|
|
2343
|
-
mockClientInfo.deviceToken = 'device-token-123';
|
|
2344
|
-
mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
|
|
2345
|
-
|
|
2346
|
-
await service.logout(mockSub, mockSessionId, true);
|
|
2347
|
-
|
|
2348
|
-
// Should still complete logout despite audit error
|
|
2349
|
-
expect(mockSessionService.revokeSession).toHaveBeenCalled();
|
|
2350
|
-
expect(mockLogger.error).toHaveBeenCalled();
|
|
2351
|
-
});
|
|
2352
|
-
});
|
|
2353
|
-
});
|
|
2354
|
-
|
|
2355
|
-
// ============================================================================
|
|
2356
|
-
// logoutAll Tests
|
|
2357
|
-
// ============================================================================
|
|
2358
|
-
|
|
2359
|
-
describe('logoutAll()', () => {
|
|
2360
|
-
const mockSub = 'user-123';
|
|
2361
|
-
const mockUserId = 1;
|
|
2362
|
-
|
|
2363
|
-
beforeEach(() => {
|
|
2364
|
-
mockUserRepository.findOne.mockResolvedValue({ ...mockUser, id: mockUserId } as any);
|
|
2365
|
-
mockSessionService.revokeAllUserSessions.mockResolvedValue(5);
|
|
2366
|
-
});
|
|
2367
|
-
|
|
2368
|
-
describe('Successful logout all', () => {
|
|
2369
|
-
it('should revoke all user sessions', async () => {
|
|
2370
|
-
await service.logoutAll(mockSub);
|
|
2371
|
-
|
|
2372
|
-
expect(mockUserRepository.findOne).toHaveBeenCalledWith({
|
|
2373
|
-
where: { sub: mockSub } as any,
|
|
2374
|
-
});
|
|
2375
|
-
expect(mockSessionService.revokeAllUserSessions).toHaveBeenCalledWith(mockUserId, 'Global signout');
|
|
2376
|
-
});
|
|
2377
|
-
|
|
2378
|
-
it('should return number of revoked sessions', async () => {
|
|
2379
|
-
const result = await service.logoutAll(mockSub);
|
|
2380
|
-
|
|
2381
|
-
expect(result).toBe(5);
|
|
2382
|
-
});
|
|
2383
|
-
|
|
2384
|
-
it('should complete logoutAll successfully', async () => {
|
|
2385
|
-
const result = await service.logoutAll(mockSub);
|
|
2386
|
-
|
|
2387
|
-
expect(result).toBe(5);
|
|
2388
|
-
expect(mockSessionService.revokeAllUserSessions).toHaveBeenCalled();
|
|
2389
|
-
});
|
|
2390
|
-
});
|
|
2391
|
-
|
|
2392
|
-
describe('User not found', () => {
|
|
2393
|
-
it('should throw NAuthException if user not found', async () => {
|
|
2394
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
2395
|
-
|
|
2396
|
-
try {
|
|
2397
|
-
await service.logoutAll(mockSub);
|
|
2398
|
-
fail('Should have thrown NAuthException');
|
|
2399
|
-
} catch (error: any) {
|
|
2400
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
2401
|
-
expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
|
|
2402
|
-
}
|
|
2403
|
-
});
|
|
2404
|
-
});
|
|
2405
|
-
|
|
2406
|
-
describe('Error handling', () => {
|
|
2407
|
-
it('should handle session revocation errors gracefully', async () => {
|
|
2408
|
-
mockSessionService.revokeAllUserSessions.mockRejectedValue(new Error('Database error'));
|
|
2409
|
-
|
|
2410
|
-
try {
|
|
2411
|
-
await service.logoutAll(mockSub);
|
|
2412
|
-
fail('Should have thrown Error');
|
|
2413
|
-
} catch (error: any) {
|
|
2414
|
-
expect(error).toBeInstanceOf(Error);
|
|
2415
|
-
}
|
|
2416
|
-
});
|
|
2417
|
-
|
|
2418
|
-
it('should complete logoutAll even if errors occur', async () => {
|
|
2419
|
-
// logoutAll doesn't directly record audit events, so this test just verifies it completes
|
|
2420
|
-
const result = await service.logoutAll(mockSub);
|
|
2421
|
-
|
|
2422
|
-
expect(result).toBe(5);
|
|
2423
|
-
});
|
|
2424
|
-
});
|
|
2425
|
-
});
|
|
2426
|
-
|
|
2427
|
-
// ============================================================================
|
|
2428
|
-
// changePassword Tests
|
|
2429
|
-
// ============================================================================
|
|
2430
|
-
|
|
2431
|
-
describe('changePassword()', () => {
|
|
2432
|
-
const changePasswordDto = {
|
|
2433
|
-
oldPassword: 'OldPassword123!',
|
|
2434
|
-
newPassword: 'NewPassword456!',
|
|
2435
|
-
};
|
|
2436
|
-
|
|
2437
|
-
beforeEach(() => {
|
|
2438
|
-
mockUserRepository.findOne.mockResolvedValue(mockUser as any);
|
|
2439
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
2440
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true } as any);
|
|
2441
|
-
mockPasswordService.isPasswordInHistory.mockResolvedValue(false);
|
|
2442
|
-
mockPasswordService.hashPassword.mockResolvedValue('new-hashed-password');
|
|
2443
|
-
mockPasswordService.addToHistory.mockReturnValue([]);
|
|
2444
|
-
mockUserRepository.save.mockResolvedValue(mockUser as any);
|
|
2445
|
-
});
|
|
2446
|
-
|
|
2447
|
-
describe('Successful password change', () => {
|
|
2448
|
-
it('should change password successfully', async () => {
|
|
2449
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2450
|
-
|
|
2451
|
-
expect(mockPasswordService.verifyPassword).toHaveBeenCalledWith(
|
|
2452
|
-
changePasswordDto.oldPassword,
|
|
2453
|
-
mockUser.passwordHash!,
|
|
2454
|
-
);
|
|
2455
|
-
expect(mockPasswordService.validatePassword).toHaveBeenCalledWith(changePasswordDto.newPassword, {
|
|
2456
|
-
email: mockUser.email,
|
|
2457
|
-
username: mockUser.username || undefined,
|
|
2458
|
-
});
|
|
2459
|
-
expect(mockPasswordService.hashPassword).toHaveBeenCalledWith(changePasswordDto.newPassword);
|
|
2460
|
-
expect(mockUserRepository.save).toHaveBeenCalled();
|
|
2461
|
-
});
|
|
2462
|
-
|
|
2463
|
-
it('should update password hash in database', async () => {
|
|
2464
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2465
|
-
|
|
2466
|
-
expect(mockUserRepository.save).toHaveBeenCalledWith(
|
|
2467
|
-
(expect as any).objectContaining({
|
|
2468
|
-
passwordHash: 'new-hashed-password',
|
|
2469
|
-
passwordChangedAt: (expect as any).any(Date),
|
|
2470
|
-
passwordHistory: (expect as any).any(Array),
|
|
2471
|
-
}),
|
|
2472
|
-
);
|
|
2473
|
-
});
|
|
2474
|
-
|
|
2475
|
-
it('should add old password to history', async () => {
|
|
2476
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2477
|
-
|
|
2478
|
-
expect(mockPasswordService.addToHistory).toHaveBeenCalledWith(
|
|
2479
|
-
mockUser.passwordHistory || [],
|
|
2480
|
-
mockUser.passwordHash!,
|
|
2481
|
-
);
|
|
2482
|
-
expect(mockSessionService.revokeAllUserSessions).toHaveBeenCalledWith(mockUser.id, 'Password changed');
|
|
2483
|
-
});
|
|
2484
|
-
|
|
2485
|
-
it('should revoke all sessions after password change', async () => {
|
|
2486
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2487
|
-
|
|
2488
|
-
expect(mockSessionService.revokeAllUserSessions).toHaveBeenCalledWith(mockUser.id, 'Password changed');
|
|
2489
|
-
});
|
|
2490
|
-
|
|
2491
|
-
it('should record PASSWORD_CHANGED audit event', async () => {
|
|
2492
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2493
|
-
|
|
2494
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
2495
|
-
(expect as any).objectContaining({
|
|
2496
|
-
userId: mockUser.id,
|
|
2497
|
-
eventType: AuthAuditEventType.PASSWORD_CHANGED,
|
|
2498
|
-
eventStatus: 'SUCCESS',
|
|
2499
|
-
}),
|
|
2500
|
-
);
|
|
2501
|
-
});
|
|
2502
|
-
});
|
|
2503
|
-
|
|
2504
|
-
describe('Password validation', () => {
|
|
2505
|
-
it('should throw NAuthException if current password is incorrect', async () => {
|
|
2506
|
-
mockPasswordService.verifyPassword.mockResolvedValue(false);
|
|
2507
|
-
|
|
2508
|
-
try {
|
|
2509
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2510
|
-
fail('Should have thrown NAuthException');
|
|
2511
|
-
} catch (error: any) {
|
|
2512
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
2513
|
-
expect(error.code).toBe(AuthErrorCode.PASSWORD_INCORRECT);
|
|
2514
|
-
}
|
|
2515
|
-
});
|
|
2516
|
-
|
|
2517
|
-
it('should throw NAuthException if new password is invalid', async () => {
|
|
2518
|
-
mockPasswordService.validatePassword.mockResolvedValue({
|
|
2519
|
-
valid: false,
|
|
2520
|
-
errors: ['Password too weak'],
|
|
2521
|
-
} as any);
|
|
2522
|
-
|
|
2523
|
-
try {
|
|
2524
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2525
|
-
fail('Should have thrown NAuthException');
|
|
2526
|
-
} catch (error: any) {
|
|
2527
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
2528
|
-
// Password validation happens in handleForceChangePassword, which throws WEAK_PASSWORD
|
|
2529
|
-
// But validation might happen earlier in validateChallengeParams
|
|
2530
|
-
expect([AuthErrorCode.WEAK_PASSWORD, AuthErrorCode.VALIDATION_FAILED]).toContain(error.code);
|
|
2531
|
-
}
|
|
2532
|
-
});
|
|
2533
|
-
|
|
2534
|
-
it('should allow password change even if hash matches (service does not prevent same password)', async () => {
|
|
2535
|
-
mockPasswordService.verifyPassword.mockResolvedValue(true);
|
|
2536
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true } as any);
|
|
2537
|
-
// Simulate same password by making hash match - service doesn't prevent this
|
|
2538
|
-
mockPasswordService.hashPassword.mockResolvedValue(mockUser.passwordHash!);
|
|
2539
|
-
|
|
2540
|
-
// Service doesn't check if new hash equals old hash, so this should succeed
|
|
2541
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2542
|
-
|
|
2543
|
-
expect(mockUserRepository.update).toHaveBeenCalled();
|
|
2544
|
-
});
|
|
2545
|
-
|
|
2546
|
-
it('should throw NAuthException if new password is in history', async () => {
|
|
2547
|
-
mockPasswordService.isPasswordInHistory.mockResolvedValue(true);
|
|
2548
|
-
|
|
2549
|
-
try {
|
|
2550
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2551
|
-
fail('Should have thrown NAuthException');
|
|
2552
|
-
} catch (error: any) {
|
|
2553
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
2554
|
-
expect(error.code).toBe(AuthErrorCode.PASSWORD_REUSED);
|
|
2555
|
-
}
|
|
2556
|
-
});
|
|
2557
|
-
});
|
|
2558
|
-
|
|
2559
|
-
describe('User not found', () => {
|
|
2560
|
-
it('should throw NAuthException if user not found', async () => {
|
|
2561
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
2562
|
-
|
|
2563
|
-
try {
|
|
2564
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2565
|
-
fail('Should have thrown NAuthException');
|
|
2566
|
-
} catch (error: any) {
|
|
2567
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
2568
|
-
expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
|
|
2569
|
-
}
|
|
2570
|
-
});
|
|
2571
|
-
});
|
|
2572
|
-
|
|
2573
|
-
describe('Social-only users', () => {
|
|
2574
|
-
it('should throw NAuthException if user has no password (social-only)', async () => {
|
|
2575
|
-
const socialUser = { ...mockUser, passwordHash: null };
|
|
2576
|
-
mockUserRepository.findOne.mockResolvedValue(socialUser as any);
|
|
2577
|
-
|
|
2578
|
-
try {
|
|
2579
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2580
|
-
fail('Should have thrown NAuthException');
|
|
2581
|
-
} catch (error: any) {
|
|
2582
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
2583
|
-
expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
|
|
2584
|
-
}
|
|
2585
|
-
});
|
|
2586
|
-
});
|
|
2587
|
-
|
|
2588
|
-
describe('Password history management', () => {
|
|
2589
|
-
it('should check password history when historyCount is configured', async () => {
|
|
2590
|
-
mockConfig.password!.historyCount = 10;
|
|
2591
|
-
const userWithHistory = { ...mockUser, passwordHistory: ['hash1', 'hash2'] };
|
|
2592
|
-
mockUserRepository.findOne.mockResolvedValue(userWithHistory as any);
|
|
2593
|
-
|
|
2594
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2595
|
-
|
|
2596
|
-
expect(mockPasswordService.isPasswordInHistory).toHaveBeenCalledWith(
|
|
2597
|
-
changePasswordDto.newPassword,
|
|
2598
|
-
userWithHistory.passwordHistory,
|
|
2599
|
-
);
|
|
2600
|
-
});
|
|
2601
|
-
|
|
2602
|
-
it('should handle empty password history', async () => {
|
|
2603
|
-
const userWithNoHistory = { ...mockUser, passwordHistory: [] };
|
|
2604
|
-
mockUserRepository.findOne.mockResolvedValue(userWithNoHistory as any);
|
|
2605
|
-
|
|
2606
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2607
|
-
|
|
2608
|
-
expect(mockPasswordService.addToHistory).toHaveBeenCalledWith([], mockUser.passwordHash!);
|
|
2609
|
-
});
|
|
2610
|
-
});
|
|
2611
|
-
|
|
2612
|
-
describe('Error handling', () => {
|
|
2613
|
-
it('should handle database update errors gracefully', async () => {
|
|
2614
|
-
mockUserRepository.update.mockRejectedValue(new Error('Database error'));
|
|
2615
|
-
|
|
2616
|
-
try {
|
|
2617
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2618
|
-
fail('Should have thrown Error');
|
|
2619
|
-
} catch (error: any) {
|
|
2620
|
-
expect(error).toBeInstanceOf(Error);
|
|
2621
|
-
}
|
|
2622
|
-
});
|
|
2623
|
-
|
|
2624
|
-
it('should handle audit logging errors gracefully', async () => {
|
|
2625
|
-
mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
|
|
2626
|
-
|
|
2627
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2628
|
-
|
|
2629
|
-
// Should still complete password change despite audit error
|
|
2630
|
-
expect(mockUserRepository.update).toHaveBeenCalled();
|
|
2631
|
-
expect(mockLogger.error).toHaveBeenCalled();
|
|
2632
|
-
});
|
|
2633
|
-
});
|
|
2634
|
-
|
|
2635
|
-
describe('Edge cases', () => {
|
|
2636
|
-
it('should handle null passwordHash gracefully', async () => {
|
|
2637
|
-
const userWithNullHash = { ...mockUser, passwordHash: null };
|
|
2638
|
-
mockUserRepository.findOne.mockResolvedValue(userWithNullHash as any);
|
|
2639
|
-
|
|
2640
|
-
try {
|
|
2641
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2642
|
-
fail('Should have thrown NAuthException');
|
|
2643
|
-
} catch (error: any) {
|
|
2644
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
2645
|
-
}
|
|
2646
|
-
});
|
|
2647
|
-
|
|
2648
|
-
it('should handle missing password history gracefully', async () => {
|
|
2649
|
-
const userWithNoHistory = { ...mockUser, passwordHistory: null };
|
|
2650
|
-
mockUserRepository.findOne.mockResolvedValue(userWithNoHistory as any);
|
|
2651
|
-
|
|
2652
|
-
await service.changePassword(mockUser.sub, changePasswordDto);
|
|
2653
|
-
|
|
2654
|
-
expect(mockPasswordService.addToHistory).toHaveBeenCalledWith([], mockUser.passwordHash!);
|
|
2655
|
-
});
|
|
2656
|
-
});
|
|
2657
|
-
});
|
|
2658
|
-
|
|
2659
|
-
// ============================================================================
|
|
2660
|
-
// updateUserAttributes Tests
|
|
2661
|
-
// ============================================================================
|
|
2662
|
-
|
|
2663
|
-
describe('updateUserAttributes()', () => {
|
|
2664
|
-
const updateData = {
|
|
2665
|
-
firstName: 'Updated',
|
|
2666
|
-
lastName: 'Name',
|
|
2667
|
-
email: 'updated@example.com',
|
|
2668
|
-
};
|
|
2669
|
-
|
|
2670
|
-
beforeEach(() => {
|
|
2671
|
-
// Setup default mock chain: initial lookup, uniqueness checks, final fetch
|
|
2672
|
-
mockUserRepository.findOne
|
|
2673
|
-
.mockResolvedValueOnce(mockUser as any) // Initial user lookup
|
|
2674
|
-
.mockResolvedValueOnce(null) // Email uniqueness check (if email in updateData)
|
|
2675
|
-
.mockResolvedValueOnce(null) // Phone uniqueness check (if phone in updateData)
|
|
2676
|
-
.mockResolvedValueOnce(null) // Username uniqueness check (if username in updateData)
|
|
2677
|
-
.mockResolvedValueOnce({ ...mockUser, ...updateData } as any); // Final fetch after update (by id)
|
|
2678
|
-
mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
|
|
2679
|
-
});
|
|
2680
|
-
|
|
2681
|
-
describe('Successful updates', () => {
|
|
2682
|
-
it('should update user attributes successfully', async () => {
|
|
2683
|
-
// Reset and setup mocks for this test
|
|
2684
|
-
mockUserRepository.findOne.mockReset();
|
|
2685
|
-
mockUserRepository.findOne
|
|
2686
|
-
.mockResolvedValueOnce(mockUser as any) // Initial lookup by sub
|
|
2687
|
-
.mockResolvedValueOnce(null) // Email uniqueness check
|
|
2688
|
-
.mockResolvedValueOnce({ ...mockUser, ...updateData } as any); // Final fetch by id
|
|
2689
|
-
|
|
2690
|
-
const result = await service.updateUserAttributes(mockUser.sub, updateData);
|
|
2691
|
-
|
|
2692
|
-
expect(mockUserRepository.findOne).toHaveBeenCalledWith({
|
|
2693
|
-
where: { sub: mockUser.sub } as any,
|
|
2694
|
-
});
|
|
2695
|
-
expect(mockUserRepository.update).toHaveBeenCalled();
|
|
2696
|
-
expect(result).toBeDefined();
|
|
2697
|
-
});
|
|
2698
|
-
|
|
2699
|
-
it('should update firstName and lastName', async () => {
|
|
2700
|
-
mockUserRepository.findOne.mockReset();
|
|
2701
|
-
mockUserRepository.findOne
|
|
2702
|
-
.mockResolvedValueOnce(mockUser as any)
|
|
2703
|
-
.mockResolvedValueOnce({ ...mockUser, firstName: 'John', lastName: 'Doe' } as any);
|
|
2704
|
-
|
|
2705
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
2706
|
-
firstName: 'John',
|
|
2707
|
-
lastName: 'Doe',
|
|
2708
|
-
});
|
|
2709
|
-
|
|
2710
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
2711
|
-
mockUser.id,
|
|
2712
|
-
(expect as any).objectContaining({
|
|
2713
|
-
firstName: 'John',
|
|
2714
|
-
lastName: 'Doe',
|
|
2715
|
-
}),
|
|
2716
|
-
);
|
|
2717
|
-
});
|
|
2718
|
-
|
|
2719
|
-
it('should update username', async () => {
|
|
2720
|
-
mockUserRepository.findOne.mockReset();
|
|
2721
|
-
mockUserRepository.findOne
|
|
2722
|
-
.mockResolvedValueOnce(mockUser as any)
|
|
2723
|
-
.mockResolvedValueOnce(null) // Username uniqueness check
|
|
2724
|
-
.mockResolvedValueOnce({ ...mockUser, username: 'newusername' } as any);
|
|
2725
|
-
|
|
2726
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
2727
|
-
username: 'newusername',
|
|
2728
|
-
});
|
|
2729
|
-
|
|
2730
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
2731
|
-
mockUser.id,
|
|
2732
|
-
(expect as any).objectContaining({
|
|
2733
|
-
username: 'newusername',
|
|
2734
|
-
}),
|
|
2735
|
-
);
|
|
2736
|
-
});
|
|
2737
|
-
|
|
2738
|
-
it('should update email and reset verification status', async () => {
|
|
2739
|
-
mockUserRepository.findOne.mockReset();
|
|
2740
|
-
mockUserRepository.findOne
|
|
2741
|
-
.mockResolvedValueOnce(mockUser as any)
|
|
2742
|
-
.mockResolvedValueOnce(null) // Email uniqueness check
|
|
2743
|
-
.mockResolvedValueOnce({ ...mockUser, email: 'newemail@example.com', isEmailVerified: false } as any);
|
|
2744
|
-
|
|
2745
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
2746
|
-
email: 'newemail@example.com',
|
|
2747
|
-
});
|
|
2748
|
-
|
|
2749
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
2750
|
-
mockUser.id,
|
|
2751
|
-
(expect as any).objectContaining({
|
|
2752
|
-
email: 'newemail@example.com',
|
|
2753
|
-
isEmailVerified: false,
|
|
2754
|
-
}),
|
|
2755
|
-
);
|
|
2756
|
-
});
|
|
2757
|
-
|
|
2758
|
-
it('should update phone and reset verification status', async () => {
|
|
2759
|
-
mockUserRepository.findOne.mockReset();
|
|
2760
|
-
mockUserRepository.findOne
|
|
2761
|
-
.mockResolvedValueOnce(mockUser as any)
|
|
2762
|
-
.mockResolvedValueOnce(null) // Phone uniqueness check
|
|
2763
|
-
.mockResolvedValueOnce({ ...mockUser, phone: '+1987654321', isPhoneVerified: false } as any);
|
|
2764
|
-
mockMfaDeviceRepository.find.mockResolvedValue([]);
|
|
2765
|
-
|
|
2766
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
2767
|
-
phone: '+1987654321',
|
|
2768
|
-
});
|
|
2769
|
-
|
|
2770
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
2771
|
-
mockUser.id,
|
|
2772
|
-
(expect as any).objectContaining({
|
|
2773
|
-
phone: '+1987654321',
|
|
2774
|
-
isPhoneVerified: false,
|
|
2775
|
-
}),
|
|
2776
|
-
);
|
|
2777
|
-
});
|
|
2778
|
-
|
|
2779
|
-
it('should retain verification status when retainVerification is true', async () => {
|
|
2780
|
-
const verifiedUser = { ...mockUser, isEmailVerified: true, isPhoneVerified: true };
|
|
2781
|
-
mockUserRepository.findOne.mockReset();
|
|
2782
|
-
mockUserRepository.findOne
|
|
2783
|
-
.mockResolvedValueOnce(verifiedUser as any) // Initial lookup by sub
|
|
2784
|
-
.mockResolvedValueOnce(null) // Email uniqueness check
|
|
2785
|
-
.mockResolvedValueOnce(null) // Phone uniqueness check
|
|
2786
|
-
.mockResolvedValueOnce({ ...verifiedUser, email: 'newemail@example.com', phone: '+1987654321' } as any); // Final fetch by id
|
|
2787
|
-
mockMfaDeviceRepository.find.mockResolvedValue([]);
|
|
2788
|
-
|
|
2789
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
2790
|
-
email: 'newemail@example.com',
|
|
2791
|
-
phone: '+1987654321',
|
|
2792
|
-
retainVerification: true,
|
|
2793
|
-
});
|
|
2794
|
-
|
|
2795
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
2796
|
-
mockUser.id,
|
|
2797
|
-
(expect as any).objectContaining({
|
|
2798
|
-
email: 'newemail@example.com',
|
|
2799
|
-
phone: '+1987654321',
|
|
2800
|
-
isEmailVerified: true,
|
|
2801
|
-
isPhoneVerified: true,
|
|
2802
|
-
}),
|
|
2803
|
-
);
|
|
2804
|
-
});
|
|
2805
|
-
|
|
2806
|
-
it('should preserve unverified status when retainVerification is true', async () => {
|
|
2807
|
-
const unverifiedUser = { ...mockUser, isEmailVerified: false, isPhoneVerified: false };
|
|
2808
|
-
mockUserRepository.findOne.mockReset();
|
|
2809
|
-
mockUserRepository.findOne
|
|
2810
|
-
.mockResolvedValueOnce(unverifiedUser as any) // Initial lookup by sub
|
|
2811
|
-
.mockResolvedValueOnce(null) // Email uniqueness check
|
|
2812
|
-
.mockResolvedValueOnce(null) // Phone uniqueness check
|
|
2813
|
-
.mockResolvedValueOnce({ ...unverifiedUser, email: 'newemail@example.com', phone: '+1987654321' } as any); // Final fetch by id
|
|
2814
|
-
mockMfaDeviceRepository.find.mockResolvedValue([]);
|
|
2815
|
-
|
|
2816
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
2817
|
-
email: 'newemail@example.com',
|
|
2818
|
-
phone: '+1987654321',
|
|
2819
|
-
retainVerification: true,
|
|
2820
|
-
});
|
|
2821
|
-
|
|
2822
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
2823
|
-
mockUser.id,
|
|
2824
|
-
(expect as any).objectContaining({
|
|
2825
|
-
email: 'newemail@example.com',
|
|
2826
|
-
phone: '+1987654321',
|
|
2827
|
-
isEmailVerified: false,
|
|
2828
|
-
isPhoneVerified: false,
|
|
2829
|
-
}),
|
|
2830
|
-
);
|
|
2831
|
-
});
|
|
2832
|
-
|
|
2833
|
-
it('should merge metadata when updating', async () => {
|
|
2834
|
-
const userWithMetadata = { ...mockUser, metadata: { key1: 'value1' } };
|
|
2835
|
-
mockUserRepository.findOne.mockReset();
|
|
2836
|
-
mockUserRepository.findOne
|
|
2837
|
-
.mockResolvedValueOnce(userWithMetadata as any) // Initial lookup by sub
|
|
2838
|
-
.mockResolvedValueOnce({ ...userWithMetadata, metadata: { key1: 'value1', key2: 'value2' } } as any); // Final fetch by id
|
|
2839
|
-
|
|
2840
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
2841
|
-
metadata: { key2: 'value2' },
|
|
2842
|
-
});
|
|
2843
|
-
|
|
2844
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
2845
|
-
mockUser.id,
|
|
2846
|
-
(expect as any).objectContaining({
|
|
2847
|
-
metadata: (expect as any).objectContaining({
|
|
2848
|
-
key1: 'value1',
|
|
2849
|
-
key2: 'value2',
|
|
2850
|
-
}),
|
|
2851
|
-
}),
|
|
2852
|
-
);
|
|
2853
|
-
});
|
|
2854
|
-
|
|
2855
|
-
it('should record PROFILE_UPDATED audit event', async () => {
|
|
2856
|
-
mockUserRepository.findOne.mockReset();
|
|
2857
|
-
mockUserRepository.findOne
|
|
2858
|
-
.mockResolvedValueOnce(mockUser as any)
|
|
2859
|
-
.mockResolvedValueOnce(null) // Email uniqueness check
|
|
2860
|
-
.mockResolvedValueOnce({ ...mockUser, ...updateData } as any);
|
|
2861
|
-
|
|
2862
|
-
await service.updateUserAttributes(mockUser.sub, updateData);
|
|
2863
|
-
|
|
2864
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
2865
|
-
(expect as any).objectContaining({
|
|
2866
|
-
userId: mockUser.id,
|
|
2867
|
-
eventType: AuthAuditEventType.PROFILE_UPDATED,
|
|
2868
|
-
eventStatus: 'INFO',
|
|
2869
|
-
}),
|
|
2870
|
-
);
|
|
2871
|
-
});
|
|
2872
|
-
});
|
|
2873
|
-
|
|
2874
|
-
describe('MFA device management', () => {
|
|
2875
|
-
it('should delete Email MFA devices when email changes', async () => {
|
|
2876
|
-
const userWithEmail = { ...mockUser, email: 'old@example.com' };
|
|
2877
|
-
const emailDevice = {
|
|
2878
|
-
id: 1,
|
|
2879
|
-
userId: mockUser.id,
|
|
2880
|
-
type: MFAMethod.EMAIL,
|
|
2881
|
-
isActive: true,
|
|
2882
|
-
};
|
|
2883
|
-
|
|
2884
|
-
mockUserRepository.findOne.mockReset();
|
|
2885
|
-
mockUserRepository.findOne
|
|
2886
|
-
.mockResolvedValueOnce(userWithEmail as any) // Initial lookup
|
|
2887
|
-
.mockResolvedValueOnce(null) // Email uniqueness check
|
|
2888
|
-
.mockResolvedValueOnce({ ...userWithEmail, email: 'new@example.com' } as any); // Final fetch
|
|
2889
|
-
|
|
2890
|
-
mockMfaDeviceRepository.find
|
|
2891
|
-
.mockResolvedValueOnce([emailDevice] as any) // Find Email devices
|
|
2892
|
-
.mockResolvedValueOnce([emailDevice] as any); // Check remaining devices
|
|
2893
|
-
|
|
2894
|
-
mockMfaDeviceRepository.delete.mockResolvedValue({ affected: 1 } as any);
|
|
2895
|
-
|
|
2896
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
2897
|
-
email: 'new@example.com',
|
|
2898
|
-
});
|
|
2899
|
-
|
|
2900
|
-
expect(mockMfaDeviceRepository.delete).toHaveBeenCalledWith(1);
|
|
2901
|
-
});
|
|
2902
|
-
|
|
2903
|
-
it('should record audit event when Email MFA devices are deleted', async () => {
|
|
2904
|
-
const userWithEmail = { ...mockUser, email: 'old@example.com' };
|
|
2905
|
-
const emailDevice = {
|
|
2906
|
-
id: 1,
|
|
2907
|
-
userId: mockUser.id,
|
|
2908
|
-
type: MFAMethod.EMAIL,
|
|
2909
|
-
isActive: true,
|
|
2910
|
-
};
|
|
2911
|
-
|
|
2912
|
-
mockUserRepository.findOne.mockReset();
|
|
2913
|
-
mockUserRepository.findOne
|
|
2914
|
-
.mockResolvedValueOnce(userWithEmail as any)
|
|
2915
|
-
.mockResolvedValueOnce(null)
|
|
2916
|
-
.mockResolvedValueOnce({ ...userWithEmail, email: 'new@example.com' } as any);
|
|
2917
|
-
|
|
2918
|
-
mockMfaDeviceRepository.find
|
|
2919
|
-
.mockResolvedValueOnce([emailDevice] as any)
|
|
2920
|
-
.mockResolvedValueOnce([emailDevice] as any);
|
|
2921
|
-
|
|
2922
|
-
mockMfaDeviceRepository.delete.mockResolvedValue({ affected: 1 } as any);
|
|
2923
|
-
|
|
2924
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
2925
|
-
email: 'new@example.com',
|
|
2926
|
-
});
|
|
2927
|
-
|
|
2928
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
2929
|
-
(expect as any).objectContaining({
|
|
2930
|
-
userId: mockUser.id,
|
|
2931
|
-
eventType: AuthAuditEventType.MFA_DEVICE_REMOVED,
|
|
2932
|
-
eventStatus: 'INFO',
|
|
2933
|
-
reason: 'email_changed',
|
|
2934
|
-
metadata: (expect as any).objectContaining({
|
|
2935
|
-
method: MFAMethod.EMAIL,
|
|
2936
|
-
deletedCount: 1,
|
|
2937
|
-
oldEmail: 'old@example.com',
|
|
2938
|
-
newEmail: 'new@example.com',
|
|
2939
|
-
}),
|
|
2940
|
-
}),
|
|
2941
|
-
);
|
|
2942
|
-
});
|
|
2943
|
-
|
|
2944
|
-
it('should delete SMS MFA devices when phone changes', async () => {
|
|
2945
|
-
const userWithPhone = { ...mockUser, phone: '+1234567890' };
|
|
2946
|
-
const smsDevice = {
|
|
2947
|
-
id: 1,
|
|
2948
|
-
userId: mockUser.id,
|
|
2949
|
-
type: MFAMethod.SMS,
|
|
2950
|
-
phoneNumber: '+1234567890',
|
|
2951
|
-
isActive: true,
|
|
2952
|
-
};
|
|
2953
|
-
|
|
2954
|
-
mockUserRepository.findOne.mockReset();
|
|
2955
|
-
mockUserRepository.findOne
|
|
2956
|
-
.mockResolvedValueOnce(userWithPhone as any) // Initial lookup
|
|
2957
|
-
.mockResolvedValueOnce(null) // Phone uniqueness check
|
|
2958
|
-
.mockResolvedValueOnce({ ...userWithPhone, phone: '+1987654321' } as any); // Final fetch
|
|
2959
|
-
|
|
2960
|
-
mockMfaDeviceRepository.find
|
|
2961
|
-
.mockResolvedValueOnce([smsDevice] as any) // Find SMS devices
|
|
2962
|
-
.mockResolvedValueOnce([smsDevice] as any); // Check remaining devices
|
|
2963
|
-
|
|
2964
|
-
mockMfaDeviceRepository.delete.mockResolvedValue({ affected: 1 } as any);
|
|
2965
|
-
|
|
2966
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
2967
|
-
phone: '+1987654321',
|
|
2968
|
-
});
|
|
2969
|
-
|
|
2970
|
-
expect(mockMfaDeviceRepository.delete).toHaveBeenCalledWith(1);
|
|
2971
|
-
});
|
|
2972
|
-
|
|
2973
|
-
it('should record audit event when SMS MFA devices are deleted', async () => {
|
|
2974
|
-
const userWithPhone = { ...mockUser, phone: '+1234567890' };
|
|
2975
|
-
const smsDevice = {
|
|
2976
|
-
id: 1,
|
|
2977
|
-
userId: mockUser.id,
|
|
2978
|
-
type: MFAMethod.SMS,
|
|
2979
|
-
phoneNumber: '+1234567890',
|
|
2980
|
-
isActive: true,
|
|
2981
|
-
};
|
|
2982
|
-
|
|
2983
|
-
mockUserRepository.findOne.mockReset();
|
|
2984
|
-
mockUserRepository.findOne
|
|
2985
|
-
.mockResolvedValueOnce(userWithPhone as any)
|
|
2986
|
-
.mockResolvedValueOnce(null)
|
|
2987
|
-
.mockResolvedValueOnce({ ...userWithPhone, phone: '+1987654321' } as any);
|
|
2988
|
-
|
|
2989
|
-
mockMfaDeviceRepository.find
|
|
2990
|
-
.mockResolvedValueOnce([smsDevice] as any)
|
|
2991
|
-
.mockResolvedValueOnce([smsDevice] as any);
|
|
2992
|
-
|
|
2993
|
-
mockMfaDeviceRepository.delete.mockResolvedValue({ affected: 1 } as any);
|
|
2994
|
-
|
|
2995
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
2996
|
-
phone: '+1987654321',
|
|
2997
|
-
});
|
|
2998
|
-
|
|
2999
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
3000
|
-
(expect as any).objectContaining({
|
|
3001
|
-
userId: mockUser.id,
|
|
3002
|
-
eventType: AuthAuditEventType.MFA_DEVICE_REMOVED,
|
|
3003
|
-
eventStatus: 'INFO',
|
|
3004
|
-
reason: 'phone_changed',
|
|
3005
|
-
metadata: (expect as any).objectContaining({
|
|
3006
|
-
method: MFAMethod.SMS,
|
|
3007
|
-
deletedCount: 1,
|
|
3008
|
-
oldPhone: '+1234567890',
|
|
3009
|
-
newPhone: '+1987654321',
|
|
3010
|
-
}),
|
|
3011
|
-
}),
|
|
3012
|
-
);
|
|
3013
|
-
});
|
|
3014
|
-
|
|
3015
|
-
it('should disable MFA when all devices are removed after email change', async () => {
|
|
3016
|
-
const userWithMfa = { ...mockUser, email: 'old@example.com', mfaEnabled: true };
|
|
3017
|
-
const emailDevice = {
|
|
3018
|
-
id: 1,
|
|
3019
|
-
userId: mockUser.id,
|
|
3020
|
-
type: MFAMethod.EMAIL,
|
|
3021
|
-
isActive: true,
|
|
3022
|
-
};
|
|
3023
|
-
|
|
3024
|
-
mockUserRepository.findOne.mockReset();
|
|
3025
|
-
mockUserRepository.findOne
|
|
3026
|
-
.mockResolvedValueOnce(userWithMfa as any)
|
|
3027
|
-
.mockResolvedValueOnce(null)
|
|
3028
|
-
.mockResolvedValueOnce({ ...userWithMfa, email: 'new@example.com', mfaEnabled: false } as any);
|
|
3029
|
-
|
|
3030
|
-
mockMfaDeviceRepository.find
|
|
3031
|
-
.mockResolvedValueOnce([emailDevice] as any) // Find Email devices
|
|
3032
|
-
.mockResolvedValueOnce([] as any); // No remaining devices
|
|
3033
|
-
|
|
3034
|
-
mockMfaDeviceRepository.delete.mockResolvedValue({ affected: 1 } as any);
|
|
3035
|
-
|
|
3036
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
3037
|
-
email: 'new@example.com',
|
|
3038
|
-
});
|
|
3039
|
-
|
|
3040
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
3041
|
-
mockUser.id,
|
|
3042
|
-
(expect as any).objectContaining({
|
|
3043
|
-
email: 'new@example.com',
|
|
3044
|
-
mfaEnabled: false,
|
|
3045
|
-
mfaMethods: [],
|
|
3046
|
-
preferredMfaMethod: null,
|
|
3047
|
-
}),
|
|
3048
|
-
);
|
|
3049
|
-
});
|
|
3050
|
-
|
|
3051
|
-
it('should disable MFA when all devices are removed after phone change', async () => {
|
|
3052
|
-
const userWithMfa = { ...mockUser, phone: '+1234567890', mfaEnabled: true };
|
|
3053
|
-
const smsDevice = {
|
|
3054
|
-
id: 1,
|
|
3055
|
-
userId: mockUser.id,
|
|
3056
|
-
type: MFAMethod.SMS,
|
|
3057
|
-
phoneNumber: '+1234567890',
|
|
3058
|
-
isActive: true,
|
|
3059
|
-
};
|
|
3060
|
-
|
|
3061
|
-
mockUserRepository.findOne.mockReset();
|
|
3062
|
-
mockUserRepository.findOne
|
|
3063
|
-
.mockResolvedValueOnce(userWithMfa as any)
|
|
3064
|
-
.mockResolvedValueOnce(null)
|
|
3065
|
-
.mockResolvedValueOnce({ ...userWithMfa, phone: '+1987654321', mfaEnabled: false } as any);
|
|
3066
|
-
|
|
3067
|
-
mockMfaDeviceRepository.find
|
|
3068
|
-
.mockResolvedValueOnce([smsDevice] as any) // Find SMS devices
|
|
3069
|
-
.mockResolvedValueOnce([] as any); // No remaining devices
|
|
3070
|
-
|
|
3071
|
-
mockMfaDeviceRepository.delete.mockResolvedValue({ affected: 1 } as any);
|
|
3072
|
-
|
|
3073
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
3074
|
-
phone: '+1987654321',
|
|
3075
|
-
});
|
|
3076
|
-
|
|
3077
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
3078
|
-
mockUser.id,
|
|
3079
|
-
(expect as any).objectContaining({
|
|
3080
|
-
phone: '+1987654321',
|
|
3081
|
-
mfaEnabled: false,
|
|
3082
|
-
mfaMethods: [],
|
|
3083
|
-
preferredMfaMethod: null,
|
|
3084
|
-
}),
|
|
3085
|
-
);
|
|
3086
|
-
});
|
|
3087
|
-
});
|
|
3088
|
-
|
|
3089
|
-
describe('Uniqueness constraints', () => {
|
|
3090
|
-
it('should throw NAuthException if email already exists', async () => {
|
|
3091
|
-
mockUserRepository.findOne.mockReset();
|
|
3092
|
-
mockUserRepository.findOne
|
|
3093
|
-
.mockResolvedValueOnce(mockUser as any) // First call for user lookup by sub
|
|
3094
|
-
.mockResolvedValueOnce({ id: 999 } as any); // Second call for email uniqueness check
|
|
3095
|
-
|
|
3096
|
-
try {
|
|
3097
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
3098
|
-
email: 'existing@example.com',
|
|
3099
|
-
});
|
|
3100
|
-
fail('Should have thrown NAuthException');
|
|
3101
|
-
} catch (error: any) {
|
|
3102
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3103
|
-
expect(error.code).toBe(AuthErrorCode.VALIDATION_FAILED);
|
|
3104
|
-
}
|
|
3105
|
-
});
|
|
3106
|
-
|
|
3107
|
-
it('should throw NAuthException if phone already exists', async () => {
|
|
3108
|
-
mockUserRepository.findOne.mockReset();
|
|
3109
|
-
mockUserRepository.findOne
|
|
3110
|
-
.mockResolvedValueOnce(mockUser as any) // First call for user lookup by sub
|
|
3111
|
-
.mockResolvedValueOnce({ id: 999 } as any); // Second call for phone uniqueness check
|
|
3112
|
-
|
|
3113
|
-
try {
|
|
3114
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
3115
|
-
phone: '+1234567890',
|
|
3116
|
-
});
|
|
3117
|
-
fail('Should have thrown NAuthException');
|
|
3118
|
-
} catch (error: any) {
|
|
3119
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3120
|
-
expect(error.code).toBe(AuthErrorCode.VALIDATION_FAILED);
|
|
3121
|
-
}
|
|
3122
|
-
});
|
|
3123
|
-
|
|
3124
|
-
it('should throw NAuthException if username already exists', async () => {
|
|
3125
|
-
mockUserRepository.findOne.mockReset();
|
|
3126
|
-
mockUserRepository.findOne
|
|
3127
|
-
.mockResolvedValueOnce(mockUser as any) // First call for user lookup by sub
|
|
3128
|
-
.mockResolvedValueOnce({ id: 999 } as any); // Second call for username uniqueness check
|
|
3129
|
-
|
|
3130
|
-
try {
|
|
3131
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
3132
|
-
username: 'existinguser',
|
|
3133
|
-
});
|
|
3134
|
-
fail('Should have thrown NAuthException');
|
|
3135
|
-
} catch (error: any) {
|
|
3136
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3137
|
-
expect(error.code).toBe(AuthErrorCode.VALIDATION_FAILED);
|
|
3138
|
-
}
|
|
3139
|
-
});
|
|
3140
|
-
|
|
3141
|
-
it('should allow updating to same email', async () => {
|
|
3142
|
-
mockUserRepository.findOne.mockReset();
|
|
3143
|
-
mockUserRepository.findOne
|
|
3144
|
-
.mockResolvedValueOnce(mockUser as any) // First call for user lookup by sub
|
|
3145
|
-
.mockResolvedValueOnce(null) // Email uniqueness check (not found = OK, because it's the same user)
|
|
3146
|
-
.mockResolvedValueOnce(mockUser as any); // Final fetch by id
|
|
3147
|
-
|
|
3148
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
3149
|
-
email: mockUser.email,
|
|
3150
|
-
});
|
|
3151
|
-
|
|
3152
|
-
expect(mockUserRepository.update).toHaveBeenCalled();
|
|
3153
|
-
});
|
|
3154
|
-
});
|
|
3155
|
-
|
|
3156
|
-
describe('User not found', () => {
|
|
3157
|
-
it('should throw NAuthException if user not found', async () => {
|
|
3158
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
3159
|
-
|
|
3160
|
-
try {
|
|
3161
|
-
await service.updateUserAttributes(mockUser.sub, updateData);
|
|
3162
|
-
fail('Should have thrown NAuthException');
|
|
3163
|
-
} catch (error: any) {
|
|
3164
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3165
|
-
expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
|
|
3166
|
-
}
|
|
3167
|
-
});
|
|
3168
|
-
});
|
|
3169
|
-
|
|
3170
|
-
describe('MFA device management', () => {
|
|
3171
|
-
it('should deactivate SMS MFA devices when phone changes', async () => {
|
|
3172
|
-
const oldPhone = '+1234567890';
|
|
3173
|
-
const userWithPhone = { ...mockUser, phone: oldPhone };
|
|
3174
|
-
mockUserRepository.findOne.mockReset();
|
|
3175
|
-
mockUserRepository.findOne
|
|
3176
|
-
.mockResolvedValueOnce(userWithPhone as any) // Initial lookup by sub
|
|
3177
|
-
.mockResolvedValueOnce(null) // Phone uniqueness check
|
|
3178
|
-
.mockResolvedValueOnce({ ...userWithPhone, phone: '+1987654321' } as any); // Final fetch by id
|
|
3179
|
-
mockMfaDeviceRepository.find.mockResolvedValue([
|
|
3180
|
-
{ id: 1, type: MFAMethod.SMS, phoneNumber: oldPhone, isActive: true },
|
|
3181
|
-
] as any);
|
|
3182
|
-
mockMfaDeviceRepository.find
|
|
3183
|
-
.mockResolvedValueOnce([{ id: 1, type: MFAMethod.SMS, phoneNumber: oldPhone, isActive: true }] as any)
|
|
3184
|
-
.mockResolvedValueOnce([] as any); // Check for remaining active devices
|
|
3185
|
-
mockMfaDeviceRepository.update.mockResolvedValue({ affected: 1 } as any);
|
|
3186
|
-
|
|
3187
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
3188
|
-
phone: '+1987654321',
|
|
3189
|
-
});
|
|
3190
|
-
|
|
3191
|
-
expect(mockMfaDeviceRepository.find).toHaveBeenCalled();
|
|
3192
|
-
expect(mockMfaDeviceRepository.update).toHaveBeenCalled();
|
|
3193
|
-
});
|
|
3194
|
-
|
|
3195
|
-
it('should not deactivate SMS devices if phone unchanged', async () => {
|
|
3196
|
-
const userWithPhone = { ...mockUser, phone: '+1234567890' };
|
|
3197
|
-
mockUserRepository.findOne.mockReset();
|
|
3198
|
-
mockUserRepository.findOne
|
|
3199
|
-
.mockResolvedValueOnce(userWithPhone as any) // Initial lookup
|
|
3200
|
-
.mockResolvedValueOnce(userWithPhone as any); // Final fetch
|
|
3201
|
-
|
|
3202
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
3203
|
-
firstName: 'New',
|
|
3204
|
-
});
|
|
3205
|
-
|
|
3206
|
-
expect(mockMfaDeviceRepository.find).not.toHaveBeenCalled();
|
|
3207
|
-
});
|
|
3208
|
-
|
|
3209
|
-
it('should deactivate SMS MFA devices when phone changes even if retainVerification is true', async () => {
|
|
3210
|
-
const oldPhone = '+1234567890';
|
|
3211
|
-
const userWithPhone = { ...mockUser, phone: oldPhone, isPhoneVerified: true };
|
|
3212
|
-
mockUserRepository.findOne.mockReset();
|
|
3213
|
-
mockUserRepository.findOne
|
|
3214
|
-
.mockResolvedValueOnce(userWithPhone as any) // Initial lookup
|
|
3215
|
-
.mockResolvedValueOnce(null) // Phone uniqueness check
|
|
3216
|
-
.mockResolvedValueOnce({ ...userWithPhone, phone: '+1987654321' } as any); // Final fetch
|
|
3217
|
-
mockMfaDeviceRepository.find
|
|
3218
|
-
.mockResolvedValueOnce([{ id: 1, type: MFAMethod.SMS, phoneNumber: oldPhone, isActive: true }] as any) // Find SMS devices with old phone
|
|
3219
|
-
.mockResolvedValueOnce([] as any); // Check for remaining active devices
|
|
3220
|
-
mockMfaDeviceRepository.update.mockResolvedValue({ affected: 1 } as any);
|
|
3221
|
-
|
|
3222
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
3223
|
-
phone: '+1987654321',
|
|
3224
|
-
retainVerification: true,
|
|
3225
|
-
});
|
|
3226
|
-
|
|
3227
|
-
// Should preserve verification status
|
|
3228
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
3229
|
-
mockUser.id,
|
|
3230
|
-
(expect as any).objectContaining({
|
|
3231
|
-
phone: '+1987654321',
|
|
3232
|
-
isPhoneVerified: true, // Preserved because retainVerification is true
|
|
3233
|
-
}),
|
|
3234
|
-
);
|
|
3235
|
-
|
|
3236
|
-
// Should still deactivate MFA devices regardless of retainVerification
|
|
3237
|
-
expect(mockMfaDeviceRepository.find).toHaveBeenCalled();
|
|
3238
|
-
expect(mockMfaDeviceRepository.update).toHaveBeenCalled();
|
|
3239
|
-
});
|
|
3240
|
-
});
|
|
3241
|
-
|
|
3242
|
-
describe('Error handling', () => {
|
|
3243
|
-
it('should handle database update errors gracefully', async () => {
|
|
3244
|
-
mockUserRepository.update.mockRejectedValue(new Error('Database error'));
|
|
3245
|
-
|
|
3246
|
-
try {
|
|
3247
|
-
await service.updateUserAttributes(mockUser.sub, updateData);
|
|
3248
|
-
fail('Should have thrown Error');
|
|
3249
|
-
} catch (error: any) {
|
|
3250
|
-
expect(error).toBeInstanceOf(Error);
|
|
3251
|
-
}
|
|
3252
|
-
});
|
|
3253
|
-
|
|
3254
|
-
it('should handle audit logging errors gracefully', async () => {
|
|
3255
|
-
mockUserRepository.findOne.mockReset();
|
|
3256
|
-
mockUserRepository.findOne
|
|
3257
|
-
.mockResolvedValueOnce(mockUser as any) // Initial lookup by sub
|
|
3258
|
-
.mockResolvedValueOnce(null) // Uniqueness checks
|
|
3259
|
-
.mockResolvedValueOnce({ ...mockUser, ...updateData } as any); // Final fetch by id
|
|
3260
|
-
mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
|
|
3261
|
-
|
|
3262
|
-
await service.updateUserAttributes(mockUser.sub, updateData);
|
|
3263
|
-
|
|
3264
|
-
// Should still complete update despite audit error
|
|
3265
|
-
expect(mockUserRepository.update).toHaveBeenCalled();
|
|
3266
|
-
expect(mockLogger.error).toHaveBeenCalled();
|
|
3267
|
-
});
|
|
3268
|
-
|
|
3269
|
-
it('should handle MFA device deactivation errors gracefully', async () => {
|
|
3270
|
-
const userWithPhone = { ...mockUser, phone: '+1234567890' };
|
|
3271
|
-
mockUserRepository.findOne.mockReset();
|
|
3272
|
-
mockUserRepository.findOne
|
|
3273
|
-
.mockResolvedValueOnce(userWithPhone as any) // Initial lookup by sub
|
|
3274
|
-
.mockResolvedValueOnce(null) // Phone uniqueness check
|
|
3275
|
-
.mockResolvedValueOnce({ ...userWithPhone, phone: '+1987654321' } as any); // Final fetch by id
|
|
3276
|
-
mockMfaDeviceRepository.find.mockRejectedValue(new Error('Database error'));
|
|
3277
|
-
|
|
3278
|
-
// Should still complete update despite MFA device error
|
|
3279
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
3280
|
-
phone: '+1987654321',
|
|
3281
|
-
});
|
|
3282
|
-
|
|
3283
|
-
expect(mockUserRepository.update).toHaveBeenCalled();
|
|
3284
|
-
expect(mockLogger.warn).toHaveBeenCalled();
|
|
3285
|
-
});
|
|
3286
|
-
});
|
|
3287
|
-
|
|
3288
|
-
describe('Edge cases', () => {
|
|
3289
|
-
it('should handle partial updates (only some fields)', async () => {
|
|
3290
|
-
mockUserRepository.findOne.mockReset();
|
|
3291
|
-
mockUserRepository.findOne
|
|
3292
|
-
.mockResolvedValueOnce(mockUser as any)
|
|
3293
|
-
.mockResolvedValueOnce({ ...mockUser, firstName: 'NewFirst' } as any);
|
|
3294
|
-
|
|
3295
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
3296
|
-
firstName: 'NewFirst',
|
|
3297
|
-
});
|
|
3298
|
-
|
|
3299
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
3300
|
-
mockUser.id,
|
|
3301
|
-
(expect as any).objectContaining({
|
|
3302
|
-
firstName: 'NewFirst',
|
|
3303
|
-
}),
|
|
3304
|
-
);
|
|
3305
|
-
});
|
|
3306
|
-
|
|
3307
|
-
it('should handle empty metadata', async () => {
|
|
3308
|
-
const userWithMetadata = { ...mockUser, metadata: { key1: 'value1' } };
|
|
3309
|
-
mockUserRepository.findOne.mockReset();
|
|
3310
|
-
mockUserRepository.findOne
|
|
3311
|
-
.mockResolvedValueOnce(userWithMetadata as any) // Initial lookup by sub
|
|
3312
|
-
.mockResolvedValueOnce(userWithMetadata as any); // Final fetch by id
|
|
3313
|
-
|
|
3314
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
3315
|
-
metadata: {},
|
|
3316
|
-
});
|
|
3317
|
-
|
|
3318
|
-
expect(mockUserRepository.update).toHaveBeenCalled();
|
|
3319
|
-
});
|
|
3320
|
-
|
|
3321
|
-
it('should handle null metadata', async () => {
|
|
3322
|
-
const userWithNullMetadata = { ...mockUser, metadata: null };
|
|
3323
|
-
mockUserRepository.findOne.mockReset();
|
|
3324
|
-
mockUserRepository.findOne
|
|
3325
|
-
.mockResolvedValueOnce(userWithNullMetadata as any) // Initial lookup by sub
|
|
3326
|
-
.mockResolvedValueOnce({ ...userWithNullMetadata, metadata: { key: 'value' } } as any); // Final fetch by id
|
|
3327
|
-
|
|
3328
|
-
await service.updateUserAttributes(mockUser.sub, {
|
|
3329
|
-
metadata: { key: 'value' },
|
|
3330
|
-
});
|
|
3331
|
-
|
|
3332
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
3333
|
-
mockUser.id,
|
|
3334
|
-
(expect as any).objectContaining({
|
|
3335
|
-
metadata: (expect as any).objectContaining({
|
|
3336
|
-
key: 'value',
|
|
3337
|
-
}),
|
|
3338
|
-
}),
|
|
3339
|
-
);
|
|
3340
|
-
});
|
|
3341
|
-
});
|
|
3342
|
-
});
|
|
3343
|
-
|
|
3344
|
-
// ============================================================================
|
|
3345
|
-
// respondToChallenge - MFA_REQUIRED Tests (formerly verifyMFA)
|
|
3346
|
-
// ============================================================================
|
|
3347
|
-
|
|
3348
|
-
describe('respondToChallenge() - MFA_REQUIRED', () => {
|
|
3349
|
-
const mockChallengeSession = {
|
|
3350
|
-
id: 'challenge-session-123',
|
|
3351
|
-
sessionToken: 'challenge-session-123',
|
|
3352
|
-
user: mockUser,
|
|
3353
|
-
challengeName: AuthChallenge.MFA_REQUIRED,
|
|
3354
|
-
metadata: {},
|
|
3355
|
-
};
|
|
3356
|
-
|
|
3357
|
-
beforeEach(() => {
|
|
3358
|
-
mockChallengeService.validateSession.mockResolvedValue(mockChallengeSession as any);
|
|
3359
|
-
mockChallengeService.validateAndConsumeSession.mockResolvedValue(mockChallengeSession as any);
|
|
3360
|
-
mockMfaService.verifyCode.mockResolvedValue(true);
|
|
3361
|
-
mockJwtService.generateTokenFamily.mockReturnValue('token-family-123');
|
|
3362
|
-
mockJwtService.generateTokenPair.mockResolvedValue({
|
|
3363
|
-
accessToken: 'access-token',
|
|
3364
|
-
refreshToken: 'refresh-token',
|
|
3365
|
-
expiresIn: 3600,
|
|
3366
|
-
});
|
|
3367
|
-
mockJwtService.hashToken.mockReturnValue('hashed-token');
|
|
3368
|
-
mockJwtService.validateAccessToken.mockResolvedValue({
|
|
3369
|
-
valid: true,
|
|
3370
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 3600 },
|
|
3371
|
-
} as any);
|
|
3372
|
-
mockJwtService.validateRefreshToken.mockResolvedValue({
|
|
3373
|
-
valid: true,
|
|
3374
|
-
payload: { exp: Math.floor(Date.now() / 1000) + 86400 },
|
|
3375
|
-
} as any);
|
|
3376
|
-
mockSessionService.createSessionAtomic.mockResolvedValue({
|
|
3377
|
-
session: mockSession,
|
|
3378
|
-
extra: {
|
|
3379
|
-
accessToken: 'access-token',
|
|
3380
|
-
refreshToken: 'refresh-token',
|
|
3381
|
-
},
|
|
3382
|
-
} as any);
|
|
3383
|
-
mockAccountLockoutStorage.resetFailedAttempts.mockResolvedValue(undefined);
|
|
3384
|
-
});
|
|
3385
|
-
|
|
3386
|
-
describe('Successful MFA verification', () => {
|
|
3387
|
-
it('should verify TOTP code successfully', async () => {
|
|
3388
|
-
const response: VerifyMFACodeResponse = {
|
|
3389
|
-
session: 'challenge-session-123',
|
|
3390
|
-
type: 'MFA_REQUIRED',
|
|
3391
|
-
method: 'totp',
|
|
3392
|
-
code: '123456',
|
|
3393
|
-
};
|
|
3394
|
-
const result = await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3395
|
-
|
|
3396
|
-
expect(mockChallengeService.validateSession).toHaveBeenCalledWith('challenge-session-123');
|
|
3397
|
-
expect(mockMfaService.verifyCode).toHaveBeenCalledWith(mockUser, 'totp', '123456');
|
|
3398
|
-
expect(mockChallengeService.validateAndConsumeSession).toHaveBeenCalledWith(
|
|
3399
|
-
'challenge-session-123',
|
|
3400
|
-
AuthChallenge.MFA_REQUIRED,
|
|
3401
|
-
);
|
|
3402
|
-
expect(result).toBeDefined();
|
|
3403
|
-
if ('accessToken' in result) {
|
|
3404
|
-
expect(result.accessToken).toBe('access-token');
|
|
3405
|
-
expect(result.refreshToken).toBe('refresh-token');
|
|
3406
|
-
}
|
|
3407
|
-
});
|
|
3408
|
-
|
|
3409
|
-
it('should verify SMS code successfully', async () => {
|
|
3410
|
-
const response: VerifyMFACodeResponse = {
|
|
3411
|
-
session: 'challenge-session-123',
|
|
3412
|
-
type: 'MFA_REQUIRED',
|
|
3413
|
-
method: 'sms',
|
|
3414
|
-
code: '123456',
|
|
3415
|
-
};
|
|
3416
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3417
|
-
|
|
3418
|
-
expect(mockMfaService.verifyCode).toHaveBeenCalledWith(mockUser, 'sms', '123456');
|
|
3419
|
-
});
|
|
3420
|
-
|
|
3421
|
-
it('should verify backup code successfully', async () => {
|
|
3422
|
-
const response: VerifyMFACodeResponse = {
|
|
3423
|
-
session: 'challenge-session-123',
|
|
3424
|
-
type: 'MFA_REQUIRED',
|
|
3425
|
-
method: 'backup',
|
|
3426
|
-
code: 'backup123',
|
|
3427
|
-
};
|
|
3428
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3429
|
-
|
|
3430
|
-
expect(mockMfaService.verifyCode).toHaveBeenCalledWith(mockUser, 'backup', 'backup123');
|
|
3431
|
-
});
|
|
3432
|
-
|
|
3433
|
-
it('should verify passkey credential successfully', async () => {
|
|
3434
|
-
const credential = { id: 'passkey-id', response: {} };
|
|
3435
|
-
const sessionWithPasskey = {
|
|
3436
|
-
...mockChallengeSession,
|
|
3437
|
-
metadata: { passkeyChallenge: 'expected-challenge' },
|
|
3438
|
-
};
|
|
3439
|
-
mockChallengeService.validateSession.mockResolvedValue(sessionWithPasskey as any);
|
|
3440
|
-
mockMfaService.verifyCode.mockResolvedValue(true);
|
|
3441
|
-
|
|
3442
|
-
const response: VerifyMFAPasskeyResponse = {
|
|
3443
|
-
session: 'challenge-session-123',
|
|
3444
|
-
type: 'MFA_REQUIRED',
|
|
3445
|
-
method: 'passkey',
|
|
3446
|
-
credential,
|
|
3447
|
-
};
|
|
3448
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3449
|
-
|
|
3450
|
-
expect(mockMfaService.verifyCode).toHaveBeenCalledWith(mockUser, 'passkey', {
|
|
3451
|
-
credential,
|
|
3452
|
-
expectedChallenge: 'expected-challenge',
|
|
3453
|
-
});
|
|
3454
|
-
});
|
|
3455
|
-
|
|
3456
|
-
it('should record MFA_VERIFICATION_SUCCESS audit event', async () => {
|
|
3457
|
-
const response: VerifyMFACodeResponse = {
|
|
3458
|
-
session: 'challenge-session-123',
|
|
3459
|
-
type: 'MFA_REQUIRED',
|
|
3460
|
-
method: 'totp',
|
|
3461
|
-
code: '123456',
|
|
3462
|
-
};
|
|
3463
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3464
|
-
|
|
3465
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
3466
|
-
(expect as any).objectContaining({
|
|
3467
|
-
userId: mockUser.id,
|
|
3468
|
-
eventType: AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
|
|
3469
|
-
eventStatus: 'SUCCESS',
|
|
3470
|
-
challengeSessionId: 'challenge-session-123',
|
|
3471
|
-
authMethod: 'totp',
|
|
3472
|
-
}),
|
|
3473
|
-
);
|
|
3474
|
-
});
|
|
3475
|
-
|
|
3476
|
-
it('should update user last login after successful verification', async () => {
|
|
3477
|
-
const response: VerifyMFACodeResponse = {
|
|
3478
|
-
session: 'challenge-session-123',
|
|
3479
|
-
type: 'MFA_REQUIRED',
|
|
3480
|
-
method: 'totp',
|
|
3481
|
-
code: '123456',
|
|
3482
|
-
};
|
|
3483
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3484
|
-
|
|
3485
|
-
// Note: User update happens in determineAuthResponse, not directly in MFA verification
|
|
3486
|
-
// This test may need adjustment based on actual implementation
|
|
3487
|
-
expect(mockChallengeHelper.determineAuthResponse).toHaveBeenCalled();
|
|
3488
|
-
});
|
|
3489
|
-
});
|
|
3490
|
-
|
|
3491
|
-
describe('Invalid MFA verification', () => {
|
|
3492
|
-
it('should throw NAuthException for invalid TOTP code', async () => {
|
|
3493
|
-
mockMfaService.verifyCode.mockResolvedValue(false);
|
|
3494
|
-
|
|
3495
|
-
const response: VerifyMFACodeResponse = {
|
|
3496
|
-
session: 'challenge-session-123',
|
|
3497
|
-
type: 'MFA_REQUIRED',
|
|
3498
|
-
method: 'totp',
|
|
3499
|
-
code: '123456',
|
|
3500
|
-
};
|
|
3501
|
-
|
|
3502
|
-
try {
|
|
3503
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3504
|
-
fail('Should have thrown NAuthException');
|
|
3505
|
-
} catch (error: any) {
|
|
3506
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3507
|
-
expect(error.code).toBe(AuthErrorCode.VERIFICATION_CODE_INVALID);
|
|
3508
|
-
}
|
|
3509
|
-
});
|
|
3510
|
-
|
|
3511
|
-
it('should record MFA_VERIFICATION_FAILED audit event', async () => {
|
|
3512
|
-
mockMfaService.verifyCode.mockResolvedValue(false);
|
|
3513
|
-
|
|
3514
|
-
const response: VerifyMFACodeResponse = {
|
|
3515
|
-
session: 'challenge-session-123',
|
|
3516
|
-
type: 'MFA_REQUIRED',
|
|
3517
|
-
method: 'totp',
|
|
3518
|
-
code: '123456',
|
|
3519
|
-
};
|
|
3520
|
-
|
|
3521
|
-
try {
|
|
3522
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3523
|
-
} catch {
|
|
3524
|
-
// Expected to throw
|
|
3525
|
-
}
|
|
3526
|
-
|
|
3527
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
3528
|
-
(expect as any).objectContaining({
|
|
3529
|
-
userId: mockUser.id,
|
|
3530
|
-
eventType: AuthAuditEventType.MFA_VERIFICATION_FAILED,
|
|
3531
|
-
eventStatus: 'FAILURE',
|
|
3532
|
-
challengeSessionId: 'challenge-session-123',
|
|
3533
|
-
authMethod: 'totp',
|
|
3534
|
-
}),
|
|
3535
|
-
);
|
|
3536
|
-
});
|
|
3537
|
-
|
|
3538
|
-
it('should increment challenge attempts on failure', async () => {
|
|
3539
|
-
mockMfaService.verifyCode.mockResolvedValue(false);
|
|
3540
|
-
|
|
3541
|
-
const response: VerifyMFACodeResponse = {
|
|
3542
|
-
session: 'challenge-session-123',
|
|
3543
|
-
type: 'MFA_REQUIRED',
|
|
3544
|
-
method: 'totp',
|
|
3545
|
-
code: '123456',
|
|
3546
|
-
};
|
|
3547
|
-
|
|
3548
|
-
try {
|
|
3549
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3550
|
-
} catch {
|
|
3551
|
-
// Expected to throw
|
|
3552
|
-
}
|
|
3553
|
-
|
|
3554
|
-
expect(mockChallengeService.incrementAttempts).toHaveBeenCalled();
|
|
3555
|
-
});
|
|
3556
|
-
|
|
3557
|
-
it('should throw NAuthException if code is missing for non-passkey methods', async () => {
|
|
3558
|
-
const response: VerifyMFACodeResponse = {
|
|
3559
|
-
session: 'challenge-session-123',
|
|
3560
|
-
type: 'MFA_REQUIRED',
|
|
3561
|
-
method: 'totp',
|
|
3562
|
-
code: '', // Empty code
|
|
3563
|
-
};
|
|
3564
|
-
|
|
3565
|
-
try {
|
|
3566
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3567
|
-
fail('Should have thrown NAuthException');
|
|
3568
|
-
} catch (error: any) {
|
|
3569
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3570
|
-
expect(error.code).toBe(AuthErrorCode.VALIDATION_FAILED);
|
|
3571
|
-
}
|
|
3572
|
-
});
|
|
3573
|
-
|
|
3574
|
-
it('should throw NAuthException if credential is missing for passkey', async () => {
|
|
3575
|
-
const response: VerifyMFAPasskeyResponse = {
|
|
3576
|
-
session: 'challenge-session-123',
|
|
3577
|
-
type: 'MFA_REQUIRED',
|
|
3578
|
-
method: 'passkey',
|
|
3579
|
-
credential: {} as any, // Empty credential - validation will fail
|
|
3580
|
-
};
|
|
3581
|
-
|
|
3582
|
-
try {
|
|
3583
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3584
|
-
fail('Should have thrown NAuthException');
|
|
3585
|
-
} catch (error: any) {
|
|
3586
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3587
|
-
// Validation happens in validateChallengeParams which throws VALIDATION_FAILED
|
|
3588
|
-
// but empty object might pass validation, so it could throw CHALLENGE_INVALID
|
|
3589
|
-
expect([AuthErrorCode.VALIDATION_FAILED, AuthErrorCode.CHALLENGE_INVALID]).toContain(error.code);
|
|
3590
|
-
}
|
|
3591
|
-
});
|
|
3592
|
-
|
|
3593
|
-
it('should throw NAuthException if passkey challenge is missing in session', async () => {
|
|
3594
|
-
const response: VerifyMFAPasskeyResponse = {
|
|
3595
|
-
session: 'challenge-session-123',
|
|
3596
|
-
type: 'MFA_REQUIRED',
|
|
3597
|
-
method: 'passkey',
|
|
3598
|
-
credential: { id: 'passkey' },
|
|
3599
|
-
};
|
|
3600
|
-
|
|
3601
|
-
try {
|
|
3602
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3603
|
-
fail('Should have thrown NAuthException');
|
|
3604
|
-
} catch (error: any) {
|
|
3605
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3606
|
-
expect(error.code).toBe(AuthErrorCode.CHALLENGE_INVALID);
|
|
3607
|
-
}
|
|
3608
|
-
});
|
|
3609
|
-
});
|
|
3610
|
-
|
|
3611
|
-
describe('Error handling', () => {
|
|
3612
|
-
it('should throw NAuthException if challenge session is invalid', async () => {
|
|
3613
|
-
mockChallengeService.validateSession.mockRejectedValue(
|
|
3614
|
-
new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'Invalid session'),
|
|
3615
|
-
);
|
|
3616
|
-
|
|
3617
|
-
const response: VerifyMFACodeResponse = {
|
|
3618
|
-
session: 'invalid-session',
|
|
3619
|
-
type: 'MFA_REQUIRED',
|
|
3620
|
-
method: 'totp',
|
|
3621
|
-
code: '123456',
|
|
3622
|
-
};
|
|
3623
|
-
|
|
3624
|
-
try {
|
|
3625
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3626
|
-
fail('Should have thrown NAuthException');
|
|
3627
|
-
} catch (error: any) {
|
|
3628
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3629
|
-
expect(error.code).toBe(AuthErrorCode.CHALLENGE_INVALID);
|
|
3630
|
-
}
|
|
3631
|
-
});
|
|
3632
|
-
|
|
3633
|
-
it('should throw NAuthException if user not found in challenge session', async () => {
|
|
3634
|
-
const sessionWithoutUser = { ...mockChallengeSession, user: null };
|
|
3635
|
-
mockChallengeService.validateSession.mockResolvedValue(sessionWithoutUser as any);
|
|
3636
|
-
|
|
3637
|
-
const response: VerifyMFACodeResponse = {
|
|
3638
|
-
session: 'challenge-session-123',
|
|
3639
|
-
type: 'MFA_REQUIRED',
|
|
3640
|
-
method: 'totp',
|
|
3641
|
-
code: '123456',
|
|
3642
|
-
};
|
|
3643
|
-
|
|
3644
|
-
try {
|
|
3645
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3646
|
-
fail('Should have thrown NAuthException');
|
|
3647
|
-
} catch (error: any) {
|
|
3648
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3649
|
-
expect(error.code).toBe(AuthErrorCode.CHALLENGE_INVALID);
|
|
3650
|
-
}
|
|
3651
|
-
});
|
|
3652
|
-
|
|
3653
|
-
it('should throw NAuthException if MFA service is not available', async () => {
|
|
3654
|
-
const serviceWithoutMfa = new AuthService(
|
|
3655
|
-
mockUserRepository,
|
|
3656
|
-
mockLoginAttemptRepository,
|
|
3657
|
-
mockPasswordService,
|
|
3658
|
-
mockJwtService,
|
|
3659
|
-
mockSessionService,
|
|
3660
|
-
mockChallengeService,
|
|
3661
|
-
mockChallengeHelper,
|
|
3662
|
-
mockEmailVerificationService,
|
|
3663
|
-
mockClientInfoService,
|
|
3664
|
-
mockAccountLockoutStorage,
|
|
3665
|
-
mockConfig,
|
|
3666
|
-
mockLogger,
|
|
3667
|
-
mockAuditService,
|
|
3668
|
-
mockPhoneVerificationService,
|
|
3669
|
-
undefined, // No MFA service
|
|
3670
|
-
mockMfaDeviceRepository,
|
|
3671
|
-
mockTrustedDeviceService,
|
|
3672
|
-
);
|
|
3673
|
-
|
|
3674
|
-
const response: VerifyMFACodeResponse = {
|
|
3675
|
-
session: 'challenge-session-123',
|
|
3676
|
-
type: 'MFA_REQUIRED',
|
|
3677
|
-
method: 'totp',
|
|
3678
|
-
code: '123456',
|
|
3679
|
-
};
|
|
3680
|
-
|
|
3681
|
-
try {
|
|
3682
|
-
await serviceWithoutMfa.respondToChallenge(createRespondChallengeDto(response));
|
|
3683
|
-
fail('Should have thrown NAuthException');
|
|
3684
|
-
} catch (error: any) {
|
|
3685
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3686
|
-
expect(error.code).toBe(AuthErrorCode.INTERNAL_ERROR);
|
|
3687
|
-
}
|
|
3688
|
-
});
|
|
3689
|
-
|
|
3690
|
-
it('should handle audit logging errors gracefully', async () => {
|
|
3691
|
-
mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
|
|
3692
|
-
|
|
3693
|
-
const response: VerifyMFACodeResponse = {
|
|
3694
|
-
session: 'challenge-session-123',
|
|
3695
|
-
type: 'MFA_REQUIRED',
|
|
3696
|
-
method: 'totp',
|
|
3697
|
-
code: '123456',
|
|
3698
|
-
};
|
|
3699
|
-
const result = await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3700
|
-
|
|
3701
|
-
// Should still complete verification despite audit error
|
|
3702
|
-
expect(result).toBeDefined();
|
|
3703
|
-
expect(mockLogger.error).toHaveBeenCalled();
|
|
3704
|
-
});
|
|
3705
|
-
});
|
|
3706
|
-
});
|
|
3707
|
-
|
|
3708
|
-
// ============================================================================
|
|
3709
|
-
// trustDevice Tests
|
|
3710
|
-
// ============================================================================
|
|
3711
|
-
|
|
3712
|
-
describe('trustDevice()', () => {
|
|
3713
|
-
beforeEach(() => {
|
|
3714
|
-
mockConfig.mfa = {
|
|
3715
|
-
enabled: true,
|
|
3716
|
-
enforcement: 'OPTIONAL',
|
|
3717
|
-
rememberDevices: 'user_opt_in',
|
|
3718
|
-
rememberDeviceDays: 30,
|
|
3719
|
-
};
|
|
3720
|
-
mockSessionService.findById.mockResolvedValue(mockSession as any);
|
|
3721
|
-
mockUserRepository.findOne.mockResolvedValue(mockUser as any);
|
|
3722
|
-
mockTrustedDeviceService.isDeviceTrusted.mockResolvedValue(false);
|
|
3723
|
-
mockTrustedDeviceService.createTrustedDevice.mockResolvedValue('device-token-123');
|
|
3724
|
-
});
|
|
3725
|
-
|
|
3726
|
-
describe('Successful device trust', () => {
|
|
3727
|
-
it('should create trusted device token successfully', async () => {
|
|
3728
|
-
const result = await service.trustDevice('1');
|
|
3729
|
-
|
|
3730
|
-
expect(mockSessionService.findById).toHaveBeenCalledWith(1);
|
|
3731
|
-
expect(mockTrustedDeviceService.createTrustedDevice).toHaveBeenCalled();
|
|
3732
|
-
expect(result.deviceToken).toBe('device-token-123');
|
|
3733
|
-
});
|
|
3734
|
-
|
|
3735
|
-
it('should return existing device token if device already trusted', async () => {
|
|
3736
|
-
mockTrustedDeviceService.isDeviceTrusted.mockResolvedValue(true);
|
|
3737
|
-
mockClientInfo.deviceToken = 'existing-token';
|
|
3738
|
-
|
|
3739
|
-
const result = await service.trustDevice('1');
|
|
3740
|
-
|
|
3741
|
-
expect(result.deviceToken).toBe('existing-token');
|
|
3742
|
-
expect(mockTrustedDeviceService.createTrustedDevice).not.toHaveBeenCalled();
|
|
3743
|
-
});
|
|
3744
|
-
|
|
3745
|
-
it('should revoke existing untrusted device token before creating new one', async () => {
|
|
3746
|
-
mockClientInfo.deviceToken = 'existing-untrusted-token';
|
|
3747
|
-
mockTrustedDeviceService.isDeviceTrusted.mockResolvedValue(false);
|
|
3748
|
-
|
|
3749
|
-
await service.trustDevice('1');
|
|
3750
|
-
|
|
3751
|
-
expect(mockTrustedDeviceService.revokeTrustedDevice).toHaveBeenCalled();
|
|
3752
|
-
expect(mockTrustedDeviceService.createTrustedDevice).toHaveBeenCalled();
|
|
3753
|
-
});
|
|
3754
|
-
|
|
3755
|
-
it('should record DEVICE_TRUSTED audit event', async () => {
|
|
3756
|
-
await service.trustDevice('1');
|
|
3757
|
-
|
|
3758
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalledWith(
|
|
3759
|
-
(expect as any).objectContaining({
|
|
3760
|
-
userId: mockUser.id,
|
|
3761
|
-
eventType: AuthAuditEventType.DEVICE_TRUSTED,
|
|
3762
|
-
eventStatus: 'SUCCESS',
|
|
3763
|
-
deviceId: 'device-token-123',
|
|
3764
|
-
sessionId: mockSession.id,
|
|
3765
|
-
}),
|
|
3766
|
-
);
|
|
3767
|
-
});
|
|
3768
|
-
});
|
|
3769
|
-
|
|
3770
|
-
describe('Error handling', () => {
|
|
3771
|
-
it('should throw NAuthException if rememberDevices is not user_opt_in', async () => {
|
|
3772
|
-
mockConfig.mfa = {
|
|
3773
|
-
enabled: true,
|
|
3774
|
-
enforcement: 'OPTIONAL',
|
|
3775
|
-
rememberDevices: 'always',
|
|
3776
|
-
rememberDeviceDays: 30,
|
|
3777
|
-
};
|
|
3778
|
-
|
|
3779
|
-
try {
|
|
3780
|
-
await service.trustDevice('1');
|
|
3781
|
-
fail('Should have thrown NAuthException');
|
|
3782
|
-
} catch (error: any) {
|
|
3783
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3784
|
-
expect(error.code).toBe(AuthErrorCode.FORBIDDEN);
|
|
3785
|
-
}
|
|
3786
|
-
});
|
|
3787
|
-
|
|
3788
|
-
it('should throw NAuthException if trusted device service is not available', async () => {
|
|
3789
|
-
const serviceWithoutTrustedDevice = new AuthService(
|
|
3790
|
-
mockUserRepository,
|
|
3791
|
-
mockLoginAttemptRepository,
|
|
3792
|
-
mockPasswordService,
|
|
3793
|
-
mockJwtService,
|
|
3794
|
-
mockSessionService,
|
|
3795
|
-
mockChallengeService,
|
|
3796
|
-
mockChallengeHelper,
|
|
3797
|
-
mockEmailVerificationService,
|
|
3798
|
-
mockClientInfoService,
|
|
3799
|
-
mockAccountLockoutStorage,
|
|
3800
|
-
mockConfig,
|
|
3801
|
-
mockLogger,
|
|
3802
|
-
mockAuditService,
|
|
3803
|
-
mockPhoneVerificationService,
|
|
3804
|
-
mockMfaService,
|
|
3805
|
-
mockMfaDeviceRepository,
|
|
3806
|
-
undefined, // No trusted device service
|
|
3807
|
-
);
|
|
3808
|
-
|
|
3809
|
-
try {
|
|
3810
|
-
await serviceWithoutTrustedDevice.trustDevice('1');
|
|
3811
|
-
fail('Should have thrown NAuthException');
|
|
3812
|
-
} catch (error: any) {
|
|
3813
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3814
|
-
expect(error.code).toBe(AuthErrorCode.INTERNAL_ERROR);
|
|
3815
|
-
}
|
|
3816
|
-
});
|
|
3817
|
-
|
|
3818
|
-
it('should throw NAuthException if session not found', async () => {
|
|
3819
|
-
mockSessionService.findById.mockResolvedValue(null);
|
|
3820
|
-
|
|
3821
|
-
try {
|
|
3822
|
-
await service.trustDevice('1');
|
|
3823
|
-
fail('Should have thrown NAuthException');
|
|
3824
|
-
} catch (error: any) {
|
|
3825
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3826
|
-
expect(error.code).toBe(AuthErrorCode.SESSION_NOT_FOUND);
|
|
3827
|
-
}
|
|
3828
|
-
});
|
|
3829
|
-
|
|
3830
|
-
it('should throw NAuthException if session is revoked', async () => {
|
|
3831
|
-
const revokedSession = { ...mockSession, isRevoked: true };
|
|
3832
|
-
mockSessionService.findById.mockResolvedValue(revokedSession as any);
|
|
3833
|
-
|
|
3834
|
-
try {
|
|
3835
|
-
await service.trustDevice('1');
|
|
3836
|
-
fail('Should have thrown NAuthException');
|
|
3837
|
-
} catch (error: any) {
|
|
3838
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3839
|
-
expect(error.code).toBe(AuthErrorCode.SESSION_NOT_FOUND);
|
|
3840
|
-
}
|
|
3841
|
-
});
|
|
3842
|
-
|
|
3843
|
-
it('should throw NAuthException if user not found', async () => {
|
|
3844
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
3845
|
-
|
|
3846
|
-
try {
|
|
3847
|
-
await service.trustDevice('1');
|
|
3848
|
-
fail('Should have thrown NAuthException');
|
|
3849
|
-
} catch (error: any) {
|
|
3850
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
3851
|
-
expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
|
|
3852
|
-
}
|
|
3853
|
-
});
|
|
3854
|
-
|
|
3855
|
-
it('should handle audit logging errors gracefully', async () => {
|
|
3856
|
-
mockAuditService.recordEvent.mockRejectedValue(new Error('Audit error'));
|
|
3857
|
-
|
|
3858
|
-
const result = await service.trustDevice('1');
|
|
3859
|
-
|
|
3860
|
-
// Should still complete trust operation despite audit error
|
|
3861
|
-
expect(result.deviceToken).toBe('device-token-123');
|
|
3862
|
-
expect(mockLogger.error).toHaveBeenCalled();
|
|
3863
|
-
});
|
|
3864
|
-
});
|
|
3865
|
-
});
|
|
3866
|
-
|
|
3867
|
-
// ============================================================================
|
|
3868
|
-
// respondToChallenge Tests (formerly completeChallenge)
|
|
3869
|
-
// ============================================================================
|
|
3870
|
-
|
|
3871
|
-
describe('respondToChallenge()', () => {
|
|
3872
|
-
const mockChallengeSession = {
|
|
3873
|
-
id: 'challenge-session-123',
|
|
3874
|
-
sessionToken: 'session-token',
|
|
3875
|
-
user: mockUser,
|
|
3876
|
-
challengeName: AuthChallenge.VERIFY_EMAIL,
|
|
3877
|
-
metadata: {},
|
|
3878
|
-
};
|
|
3879
|
-
|
|
3880
|
-
beforeEach(() => {
|
|
3881
|
-
mockChallengeService.validateSession.mockResolvedValue(mockChallengeSession as any);
|
|
3882
|
-
mockChallengeService.validateAndConsumeSession.mockResolvedValue(mockChallengeSession as any);
|
|
3883
|
-
// Query builder will be set up in individual tests as needed
|
|
3884
|
-
mockChallengeHelper.determineAuthResponse.mockResolvedValue({
|
|
3885
|
-
accessToken: 'access-token',
|
|
3886
|
-
refreshToken: 'refresh-token',
|
|
3887
|
-
accessTokenExpiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
3888
|
-
refreshTokenExpiresAt: Math.floor(Date.now() / 1000) + 86400,
|
|
3889
|
-
user: {
|
|
3890
|
-
sub: 'user-123',
|
|
3891
|
-
email: 'test@example.com',
|
|
3892
|
-
isEmailVerified: true,
|
|
3893
|
-
isPhoneVerified: false,
|
|
3894
|
-
},
|
|
3895
|
-
});
|
|
3896
|
-
});
|
|
3897
|
-
|
|
3898
|
-
describe('VERIFY_EMAIL challenge', () => {
|
|
3899
|
-
it('should complete email verification challenge successfully', async () => {
|
|
3900
|
-
// Mock findOne to return updated user after verification
|
|
3901
|
-
const updatedUser = {
|
|
3902
|
-
...mockUser,
|
|
3903
|
-
isEmailVerified: true,
|
|
3904
|
-
isPhoneVerified: false,
|
|
3905
|
-
};
|
|
3906
|
-
mockUserRepository.findOne.mockResolvedValue(updatedUser as any);
|
|
3907
|
-
mockEmailVerificationService.verifyEmailWithCode.mockResolvedValue({ message: 'Email verified' });
|
|
3908
|
-
|
|
3909
|
-
const response: VerifyEmailResponse = {
|
|
3910
|
-
session: 'session-token',
|
|
3911
|
-
type: 'VERIFY_EMAIL',
|
|
3912
|
-
code: '123456',
|
|
3913
|
-
};
|
|
3914
|
-
const result = await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3915
|
-
|
|
3916
|
-
expect(mockChallengeService.validateSession).toHaveBeenCalledWith('session-token');
|
|
3917
|
-
// Note: verifyEmailWithCode is called with user.sub in the implementation
|
|
3918
|
-
expect(mockEmailVerificationService.verifyEmailWithCode).toHaveBeenCalledWith(mockUser.sub, '123456');
|
|
3919
|
-
expect(mockChallengeService.validateAndConsumeSession).toHaveBeenCalledWith(
|
|
3920
|
-
'session-token',
|
|
3921
|
-
AuthChallenge.VERIFY_EMAIL,
|
|
3922
|
-
);
|
|
3923
|
-
expect(mockChallengeHelper.determineAuthResponse).toHaveBeenCalled();
|
|
3924
|
-
expect(result).toBeDefined();
|
|
3925
|
-
});
|
|
3926
|
-
});
|
|
3927
|
-
|
|
3928
|
-
describe('VERIFY_PHONE challenge', () => {
|
|
3929
|
-
it('should complete phone verification challenge successfully', async () => {
|
|
3930
|
-
const phoneVerifySession = {
|
|
3931
|
-
...mockChallengeSession,
|
|
3932
|
-
challengeName: AuthChallenge.VERIFY_PHONE,
|
|
3933
|
-
};
|
|
3934
|
-
mockChallengeService.validateSession.mockResolvedValue(phoneVerifySession as any);
|
|
3935
|
-
// Mock findOne to return updated user after verification
|
|
3936
|
-
const updatedUser = {
|
|
3937
|
-
...mockUser,
|
|
3938
|
-
isEmailVerified: true,
|
|
3939
|
-
isPhoneVerified: true,
|
|
3940
|
-
};
|
|
3941
|
-
mockUserRepository.findOne.mockResolvedValue(updatedUser as any);
|
|
3942
|
-
mockPhoneVerificationService.verifyPhoneWithCodeBySub.mockResolvedValue({
|
|
3943
|
-
message: 'Phone verified',
|
|
3944
|
-
});
|
|
3945
|
-
|
|
3946
|
-
const response: VerifyPhoneResponse = {
|
|
3947
|
-
session: 'session-token',
|
|
3948
|
-
type: 'VERIFY_PHONE',
|
|
3949
|
-
code: '123456',
|
|
3950
|
-
};
|
|
3951
|
-
const result = await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3952
|
-
|
|
3953
|
-
expect(mockChallengeService.validateSession).toHaveBeenCalledWith('session-token');
|
|
3954
|
-
expect(mockPhoneVerificationService.verifyPhoneWithCodeBySub).toHaveBeenCalledWith(mockUser.sub, '123456');
|
|
3955
|
-
expect(mockChallengeService.validateAndConsumeSession).toHaveBeenCalledWith(
|
|
3956
|
-
'session-token',
|
|
3957
|
-
AuthChallenge.VERIFY_PHONE,
|
|
3958
|
-
);
|
|
3959
|
-
expect(result).toBeDefined();
|
|
3960
|
-
});
|
|
3961
|
-
|
|
3962
|
-
it('should handle phone collection before verification', async () => {
|
|
3963
|
-
const phoneCollectSession = {
|
|
3964
|
-
...mockChallengeSession,
|
|
3965
|
-
challengeName: AuthChallenge.VERIFY_PHONE,
|
|
3966
|
-
user: mockUser,
|
|
3967
|
-
};
|
|
3968
|
-
mockChallengeService.validateSession.mockResolvedValue(phoneCollectSession as any);
|
|
3969
|
-
mockUserRepository.update.mockResolvedValue({ affected: 1 } as any);
|
|
3970
|
-
mockPhoneVerificationService.sendVerificationSMS.mockResolvedValue(undefined as any);
|
|
3971
|
-
mockChallengeHelper.createChallengeResponse.mockResolvedValue({
|
|
3972
|
-
challengeName: AuthChallenge.VERIFY_PHONE,
|
|
3973
|
-
session: 'challenge-session-token',
|
|
3974
|
-
challengeParameters: {},
|
|
3975
|
-
} as any);
|
|
3976
|
-
|
|
3977
|
-
const response: CollectPhoneResponse = {
|
|
3978
|
-
session: 'session-token',
|
|
3979
|
-
type: 'VERIFY_PHONE',
|
|
3980
|
-
phone: '+1234567890',
|
|
3981
|
-
};
|
|
3982
|
-
const result = await service.respondToChallenge(createRespondChallengeDto(response));
|
|
3983
|
-
|
|
3984
|
-
expect(mockChallengeService.validateSession).toHaveBeenCalledWith('session-token');
|
|
3985
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith({ sub: mockUser.sub }, { phone: '+1234567890' });
|
|
3986
|
-
expect(mockPhoneVerificationService.sendVerificationSMS).toHaveBeenCalledWith(mockUser.sub);
|
|
3987
|
-
expect(result.challengeName).toBeDefined();
|
|
3988
|
-
expect(result.challengeName).toBe(AuthChallenge.VERIFY_PHONE);
|
|
3989
|
-
});
|
|
3990
|
-
|
|
3991
|
-
it('should throw NAuthException for invalid phone format', async () => {
|
|
3992
|
-
const phoneCollectSession = {
|
|
3993
|
-
...mockChallengeSession,
|
|
3994
|
-
challengeName: AuthChallenge.VERIFY_PHONE,
|
|
3995
|
-
user: mockUser,
|
|
3996
|
-
};
|
|
3997
|
-
mockChallengeService.validateSession.mockResolvedValue(phoneCollectSession as any);
|
|
3998
|
-
|
|
3999
|
-
const response: CollectPhoneResponse = {
|
|
4000
|
-
session: 'session-token',
|
|
4001
|
-
type: 'VERIFY_PHONE',
|
|
4002
|
-
phone: 'invalid-phone',
|
|
4003
|
-
};
|
|
4004
|
-
|
|
4005
|
-
try {
|
|
4006
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
4007
|
-
fail('Should have thrown NAuthException');
|
|
4008
|
-
} catch (error: any) {
|
|
4009
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
4010
|
-
// Phone format validation happens in handleVerifyPhone, which throws INVALID_PHONE_FORMAT
|
|
4011
|
-
// But validation might happen earlier in validateChallengeParams
|
|
4012
|
-
expect([AuthErrorCode.INVALID_PHONE_FORMAT, AuthErrorCode.VALIDATION_FAILED]).toContain(error.code);
|
|
4013
|
-
}
|
|
4014
|
-
});
|
|
4015
|
-
});
|
|
4016
|
-
|
|
4017
|
-
describe('FORCE_CHANGE_PASSWORD challenge', () => {
|
|
4018
|
-
it('should complete password change challenge successfully', async () => {
|
|
4019
|
-
const passwordChangeSession = {
|
|
4020
|
-
...mockChallengeSession,
|
|
4021
|
-
challengeName: AuthChallenge.FORCE_CHANGE_PASSWORD,
|
|
4022
|
-
};
|
|
4023
|
-
mockChallengeService.validateSession.mockResolvedValue(passwordChangeSession as any);
|
|
4024
|
-
mockPasswordService.validatePassword.mockResolvedValue({ valid: true, errors: [] });
|
|
4025
|
-
mockPasswordService.hashPassword.mockResolvedValue('new-hashed-password');
|
|
4026
|
-
mockUserRepository.findOne.mockResolvedValue({ ...mockUser, mustChangePassword: false } as any);
|
|
4027
|
-
|
|
4028
|
-
const response: ForceChangePasswordResponse = {
|
|
4029
|
-
session: 'session-token',
|
|
4030
|
-
type: 'FORCE_CHANGE_PASSWORD',
|
|
4031
|
-
newPassword: 'NewPassword123!',
|
|
4032
|
-
};
|
|
4033
|
-
const result = await service.respondToChallenge(createRespondChallengeDto(response));
|
|
4034
|
-
|
|
4035
|
-
expect(mockChallengeService.validateSession).toHaveBeenCalledWith('session-token');
|
|
4036
|
-
expect(mockPasswordService.validatePassword).toHaveBeenCalled();
|
|
4037
|
-
expect(mockPasswordService.hashPassword).toHaveBeenCalledWith('NewPassword123!');
|
|
4038
|
-
expect(mockUserRepository.update).toHaveBeenCalledWith(
|
|
4039
|
-
{ sub: mockUser.sub },
|
|
4040
|
-
(expect as any).objectContaining({
|
|
4041
|
-
passwordHash: 'new-hashed-password',
|
|
4042
|
-
mustChangePassword: false,
|
|
4043
|
-
}),
|
|
4044
|
-
);
|
|
4045
|
-
expect(result).toBeDefined();
|
|
4046
|
-
});
|
|
4047
|
-
|
|
4048
|
-
it('should throw NAuthException if new password is missing', async () => {
|
|
4049
|
-
const passwordChangeSession = {
|
|
4050
|
-
...mockChallengeSession,
|
|
4051
|
-
challengeName: AuthChallenge.FORCE_CHANGE_PASSWORD,
|
|
4052
|
-
};
|
|
4053
|
-
mockChallengeService.validateSession.mockResolvedValue(passwordChangeSession as any);
|
|
4054
|
-
|
|
4055
|
-
const response: ForceChangePasswordResponse = {
|
|
4056
|
-
session: 'session-token',
|
|
4057
|
-
type: 'FORCE_CHANGE_PASSWORD',
|
|
4058
|
-
newPassword: '', // Empty password
|
|
4059
|
-
};
|
|
4060
|
-
|
|
4061
|
-
try {
|
|
4062
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
4063
|
-
fail('Should have thrown NAuthException');
|
|
4064
|
-
} catch (error: any) {
|
|
4065
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
4066
|
-
expect(error.code).toBe(AuthErrorCode.VALIDATION_FAILED);
|
|
4067
|
-
}
|
|
4068
|
-
});
|
|
4069
|
-
|
|
4070
|
-
it('should throw NAuthException if new password is weak', async () => {
|
|
4071
|
-
const passwordChangeSession = {
|
|
4072
|
-
...mockChallengeSession,
|
|
4073
|
-
challengeName: AuthChallenge.FORCE_CHANGE_PASSWORD,
|
|
4074
|
-
};
|
|
4075
|
-
mockChallengeService.validateSession.mockResolvedValue(passwordChangeSession as any);
|
|
4076
|
-
mockPasswordService.validatePassword.mockResolvedValue({
|
|
4077
|
-
valid: false,
|
|
4078
|
-
errors: ['Password too weak'],
|
|
4079
|
-
});
|
|
4080
|
-
|
|
4081
|
-
const response: ForceChangePasswordResponse = {
|
|
4082
|
-
session: 'session-token',
|
|
4083
|
-
type: 'FORCE_CHANGE_PASSWORD',
|
|
4084
|
-
newPassword: 'weak',
|
|
4085
|
-
};
|
|
4086
|
-
|
|
4087
|
-
try {
|
|
4088
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
4089
|
-
fail('Should have thrown NAuthException');
|
|
4090
|
-
} catch (error: any) {
|
|
4091
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
4092
|
-
// Password validation happens in handleForceChangePassword, which throws WEAK_PASSWORD
|
|
4093
|
-
// But validation might happen earlier in validateChallengeParams
|
|
4094
|
-
expect([AuthErrorCode.WEAK_PASSWORD, AuthErrorCode.VALIDATION_FAILED]).toContain(error.code);
|
|
4095
|
-
}
|
|
4096
|
-
});
|
|
4097
|
-
});
|
|
4098
|
-
|
|
4099
|
-
describe('MFA_SETUP_REQUIRED challenge', () => {
|
|
4100
|
-
it('should complete MFA setup challenge successfully', async () => {
|
|
4101
|
-
const mfaSetupSession = {
|
|
4102
|
-
...mockChallengeSession,
|
|
4103
|
-
challengeName: AuthChallenge.MFA_SETUP_REQUIRED,
|
|
4104
|
-
};
|
|
4105
|
-
mockChallengeService.validateSession.mockResolvedValue(mfaSetupSession as any);
|
|
4106
|
-
const updatedUser = { ...mockUser, mfaEnabled: true };
|
|
4107
|
-
mockUserRepository.findOne.mockResolvedValue(updatedUser as any);
|
|
4108
|
-
|
|
4109
|
-
const response: MFASetupResponse = {
|
|
4110
|
-
session: 'session-token',
|
|
4111
|
-
type: 'MFA_SETUP_REQUIRED',
|
|
4112
|
-
method: 'totp',
|
|
4113
|
-
setupData: { code: '123456' },
|
|
4114
|
-
};
|
|
4115
|
-
const result = await service.respondToChallenge(createRespondChallengeDto(response));
|
|
4116
|
-
|
|
4117
|
-
expect(mockChallengeService.validateSession).toHaveBeenCalledWith('session-token');
|
|
4118
|
-
expect(mockUserRepository.findOne).toHaveBeenCalledWith({ where: { sub: mockUser.sub } });
|
|
4119
|
-
expect(mockChallengeHelper.determineAuthResponse).toHaveBeenCalledWith({
|
|
4120
|
-
user: updatedUser,
|
|
4121
|
-
config: mockConfig,
|
|
4122
|
-
deviceToken: mockClientInfo.deviceToken,
|
|
4123
|
-
isSocialLogin: false,
|
|
4124
|
-
skipMFAVerification: true,
|
|
4125
|
-
});
|
|
4126
|
-
expect(result).toBeDefined();
|
|
4127
|
-
});
|
|
4128
|
-
|
|
4129
|
-
it('should throw NAuthException if user not found after MFA setup', async () => {
|
|
4130
|
-
const mfaSetupSession = {
|
|
4131
|
-
...mockChallengeSession,
|
|
4132
|
-
challengeName: AuthChallenge.MFA_SETUP_REQUIRED,
|
|
4133
|
-
};
|
|
4134
|
-
mockChallengeService.validateSession.mockResolvedValue(mfaSetupSession as any);
|
|
4135
|
-
mockUserRepository.findOne.mockResolvedValue(null);
|
|
4136
|
-
|
|
4137
|
-
const response: MFASetupResponse = {
|
|
4138
|
-
session: 'session-token',
|
|
4139
|
-
type: 'MFA_SETUP_REQUIRED',
|
|
4140
|
-
method: 'totp',
|
|
4141
|
-
setupData: { code: '123456' },
|
|
4142
|
-
};
|
|
4143
|
-
|
|
4144
|
-
try {
|
|
4145
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
4146
|
-
fail('Should have thrown NAuthException');
|
|
4147
|
-
} catch (error: any) {
|
|
4148
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
4149
|
-
expect(error.code).toBe(AuthErrorCode.NOT_FOUND);
|
|
4150
|
-
}
|
|
4151
|
-
});
|
|
4152
|
-
});
|
|
4153
|
-
|
|
4154
|
-
describe('Error handling', () => {
|
|
4155
|
-
it('should throw NAuthException if challenge session is invalid', async () => {
|
|
4156
|
-
mockChallengeService.validateSession.mockRejectedValue(
|
|
4157
|
-
new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'Invalid session'),
|
|
4158
|
-
);
|
|
4159
|
-
|
|
4160
|
-
const response: VerifyEmailResponse = {
|
|
4161
|
-
session: 'invalid-session',
|
|
4162
|
-
type: 'VERIFY_EMAIL',
|
|
4163
|
-
code: '123456',
|
|
4164
|
-
};
|
|
4165
|
-
|
|
4166
|
-
try {
|
|
4167
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
4168
|
-
fail('Should have thrown NAuthException');
|
|
4169
|
-
} catch (error: any) {
|
|
4170
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
4171
|
-
expect(error.code).toBe(AuthErrorCode.CHALLENGE_INVALID);
|
|
4172
|
-
}
|
|
4173
|
-
});
|
|
4174
|
-
|
|
4175
|
-
it('should throw NAuthException if user not found in challenge session', async () => {
|
|
4176
|
-
const sessionWithoutUser = { ...mockChallengeSession, user: null };
|
|
4177
|
-
mockChallengeService.validateSession.mockResolvedValue(sessionWithoutUser as any);
|
|
4178
|
-
|
|
4179
|
-
const response: VerifyEmailResponse = {
|
|
4180
|
-
session: 'session-token',
|
|
4181
|
-
type: 'VERIFY_EMAIL',
|
|
4182
|
-
code: '123456',
|
|
4183
|
-
};
|
|
4184
|
-
|
|
4185
|
-
try {
|
|
4186
|
-
await service.respondToChallenge(createRespondChallengeDto(response));
|
|
4187
|
-
fail('Should have thrown NAuthException');
|
|
4188
|
-
} catch (error: any) {
|
|
4189
|
-
expect(error).toBeInstanceOf(NAuthException);
|
|
4190
|
-
expect(error.code).toBe(AuthErrorCode.CHALLENGE_INVALID);
|
|
4191
|
-
}
|
|
4192
|
-
});
|
|
4193
|
-
});
|
|
4194
|
-
});
|
|
4195
|
-
});
|