@hed-hog/core 0.0.373 → 0.0.375

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +954 -873
  2. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  3. package/dist/integration-profile/integration-profile.secrets.js +4 -0
  4. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  5. package/dist/integration-profile/integration-profile.utils.d.ts +6 -0
  6. package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
  7. package/dist/integration-profile/integration-profile.utils.js +1 -1
  8. package/dist/integration-profile/integration-profile.utils.js.map +1 -1
  9. package/dist/oauth/oauth.controller.d.ts +11 -3
  10. package/dist/oauth/oauth.controller.d.ts.map +1 -1
  11. package/dist/oauth/oauth.controller.js +52 -14
  12. package/dist/oauth/oauth.controller.js.map +1 -1
  13. package/dist/oauth/oauth.module.d.ts.map +1 -1
  14. package/dist/oauth/oauth.module.js +4 -0
  15. package/dist/oauth/oauth.module.js.map +1 -1
  16. package/dist/oauth/oauth.service.d.ts +38 -2
  17. package/dist/oauth/oauth.service.d.ts.map +1 -1
  18. package/dist/oauth/oauth.service.js +122 -5
  19. package/dist/oauth/oauth.service.js.map +1 -1
  20. package/dist/oauth/providers/apple.provider.d.ts +34 -0
  21. package/dist/oauth/providers/apple.provider.d.ts.map +1 -0
  22. package/dist/oauth/providers/apple.provider.js +197 -0
  23. package/dist/oauth/providers/apple.provider.js.map +1 -0
  24. package/dist/oauth/providers/facebook.provider.d.ts +1 -1
  25. package/dist/oauth/providers/facebook.provider.d.ts.map +1 -1
  26. package/dist/oauth/providers/facebook.provider.js +3 -2
  27. package/dist/oauth/providers/facebook.provider.js.map +1 -1
  28. package/dist/oauth/providers/google.provider.d.ts +1 -1
  29. package/dist/oauth/providers/google.provider.d.ts.map +1 -1
  30. package/dist/oauth/providers/google.provider.js +3 -2
  31. package/dist/oauth/providers/google.provider.js.map +1 -1
  32. package/dist/oauth/providers/linkedin.provider.d.ts +25 -0
  33. package/dist/oauth/providers/linkedin.provider.d.ts.map +1 -0
  34. package/dist/oauth/providers/linkedin.provider.js +99 -0
  35. package/dist/oauth/providers/linkedin.provider.js.map +1 -0
  36. package/dist/oauth/providers/microsoft-entra-id.provider.js +1 -1
  37. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  38. package/dist/oauth/providers/microsoft.provider.d.ts +1 -1
  39. package/dist/oauth/providers/microsoft.provider.d.ts.map +1 -1
  40. package/dist/oauth/providers/microsoft.provider.js +3 -2
  41. package/dist/oauth/providers/microsoft.provider.js.map +1 -1
  42. package/dist/pdf/pdf.service.d.ts.map +1 -1
  43. package/dist/pdf/pdf.service.js +19 -2
  44. package/dist/pdf/pdf.service.js.map +1 -1
  45. package/dist/setting/setting.service.d.ts.map +1 -1
  46. package/dist/setting/setting.service.js +4 -1
  47. package/dist/setting/setting.service.js.map +1 -1
  48. package/hedhog/data/integration_provider.yaml +16 -0
  49. package/hedhog/data/setting_group.yaml +98 -0
  50. package/hedhog/data/setting_subgroup.yaml +16 -0
  51. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +2 -0
  52. package/hedhog/frontend/app/dashboard/dashboard.css.ejs +11 -11
  53. package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +42 -0
  54. package/hedhog/frontend/app/menu/page.tsx.ejs +1 -1
  55. package/hedhog/frontend/app/users/user-sheet.tsx.ejs +8 -0
  56. package/hedhog/frontend/e2e/core.spec.ts.ejs +28 -0
  57. package/hedhog/frontend/messages/en.json +35 -0
  58. package/hedhog/frontend/messages/pt.json +35 -0
  59. package/hedhog/table/user_account.yaml +1 -0
  60. package/package.json +3 -3
  61. package/src/access-log/access-log.service.spec.ts +235 -0
  62. package/src/ai-instruction/ai-prompt-builder.service.spec.ts +250 -0
  63. package/src/auth/auth.service.spec.ts +891 -0
  64. package/src/auth/guards/auth.guard.spec.ts +177 -0
  65. package/src/challenge/challenge.service.spec.ts +334 -0
  66. package/src/dashboard/dashboard-core/dashboard-core.service.spec.ts +193 -0
  67. package/src/file/image-optimization.service.spec.ts +283 -0
  68. package/src/file/provider/provider.factory.spec.ts +111 -0
  69. package/src/install/install.service.spec.ts +139 -0
  70. package/src/integration/README.md +2 -0
  71. package/src/integration/services/event-subscriber.registry.spec.ts +143 -0
  72. package/src/integration/services/inbox.service.spec.ts +183 -0
  73. package/src/integration/services/integration-link.service.spec.ts +217 -0
  74. package/src/integration/services/outbox.service.spec.ts +290 -0
  75. package/src/integration-profile/integration-credential-crypto.service.spec.ts +289 -0
  76. package/src/integration-profile/integration-profile.secrets.ts +4 -0
  77. package/src/integration-profile/integration-profile.utils.spec.ts +332 -0
  78. package/src/integration-profile/integration-profile.utils.ts +14 -1
  79. package/src/locale/locale.service.spec.ts +78 -0
  80. package/src/mail/mail.service.spec.ts +257 -0
  81. package/src/mcp/guards/mcp-auth.guard.spec.ts +87 -0
  82. package/src/mcp/guards/mcp-permission.guard.spec.ts +64 -0
  83. package/src/mcp/mcp-api-key.service.spec.ts +125 -0
  84. package/src/mcp/mcp-registry.service.spec.ts +96 -0
  85. package/src/mcp/mcp-server.service.spec.ts +187 -0
  86. package/src/menu/menu.service.spec.ts +76 -0
  87. package/src/notification/notification.service.spec.ts +297 -0
  88. package/src/oauth/oauth.controller.ts +53 -10
  89. package/src/oauth/oauth.module.ts +4 -0
  90. package/src/oauth/oauth.service.spec.ts +332 -0
  91. package/src/oauth/oauth.service.ts +156 -1
  92. package/src/oauth/providers/apple.provider.ts +175 -0
  93. package/src/oauth/providers/facebook.provider.ts +3 -2
  94. package/src/oauth/providers/google.provider.ts +3 -2
  95. package/src/oauth/providers/linkedin.provider.ts +96 -0
  96. package/src/oauth/providers/microsoft-entra-id.provider.ts +1 -1
  97. package/src/oauth/providers/microsoft.provider.ts +3 -2
  98. package/src/pdf/pdf.service.ts +20 -2
  99. package/src/role/guards/role.guard.spec.ts +157 -0
  100. package/src/role/role.service.spec.ts +179 -0
  101. package/src/session/session.service.spec.ts +376 -0
  102. package/src/setting/setting.service.spec.ts +316 -0
  103. package/src/setting/setting.service.ts +4 -1
  104. package/src/task/task.service.spec.ts +47 -0
  105. package/src/token/token.service.spec.ts +351 -0
  106. package/src/user/user.service.spec.ts +256 -0
  107. package/src/utils/locale-context.spec.ts +22 -0
  108. package/src/utils/webauthn-origin.spec.ts +73 -0
  109. package/src/webhook-integration/integration-event-catalog.service.spec.ts +149 -0
@@ -0,0 +1,891 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import {
3
+ BadRequestException,
4
+ NotFoundException,
5
+ UnauthorizedException,
6
+ } from '@nestjs/common';
7
+
8
+ const speakeasyVerify = jest.fn();
9
+ jest.mock('speakeasy', () => ({
10
+ totp: { verify: (...args: any[]) => speakeasyVerify(...args) },
11
+ }));
12
+
13
+ import { AuthService } from './auth.service';
14
+
15
+ const makeDeps = () => {
16
+ const prisma = {
17
+ user: { update: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn() },
18
+ user_credential: { findFirst: jest.fn(), updateMany: jest.fn() },
19
+ user_identifier: { findFirst: jest.fn(), findMany: jest.fn() },
20
+ user_identifier_challenge: {
21
+ create: jest.fn(),
22
+ findUnique: jest.fn(),
23
+ update: jest.fn(),
24
+ },
25
+ user_mfa: { create: jest.fn(), findMany: jest.fn() },
26
+ user_mfa_challenge: { create: jest.fn(), findFirst: jest.fn(), update: jest.fn() },
27
+ user_mfa_webauthn: { update: jest.fn() },
28
+ user_recovery_code: { delete: jest.fn() },
29
+ role: { findMany: jest.fn() },
30
+ $transaction: jest.fn(),
31
+ };
32
+ const mail = { sendTemplatedMail: jest.fn().mockResolvedValue(undefined) };
33
+ const setting = { getSettingValues: jest.fn() };
34
+ const security = {
35
+ generateCode: jest.fn(),
36
+ hashWithPepper: jest.fn(),
37
+ validatePassword: jest.fn(),
38
+ hashArgon2: jest.fn(),
39
+ verifyArgon2: jest.fn(),
40
+ };
41
+ const token = {
42
+ createAccessToken: jest.fn(),
43
+ verify: jest.fn(),
44
+ decodeExpiredToken: jest.fn(),
45
+ createMfaChallengeToken: jest.fn(),
46
+ verifyMfaChallengeToken: jest.fn(),
47
+ setRefreshTokenCookie: jest.fn(),
48
+ };
49
+ const session = {
50
+ create: jest.fn(),
51
+ refresh: jest.fn(),
52
+ deleteRefreshTokenCookie: jest.fn(),
53
+ };
54
+ const user = {
55
+ findUserByEmail: jest.fn(),
56
+ findUserById: jest.fn(),
57
+ registerUserActivity: jest.fn(),
58
+ };
59
+ const challenge = {
60
+ sendMfaCodeToMultipleEmails: jest.fn(),
61
+ sendMfaCodeByEmail: jest.fn(),
62
+ forgotEmail: jest.fn(),
63
+ verifyCodePassword: jest.fn(),
64
+ verifyMfaEmailCode: jest.fn(),
65
+ };
66
+ return { prisma, mail, setting, security, token, session, user, challenge };
67
+ };
68
+
69
+ const makeService = (deps = makeDeps()) => ({
70
+ service: new AuthService(
71
+ deps.prisma as any,
72
+ deps.mail as any,
73
+ deps.setting as any,
74
+ deps.security as any,
75
+ deps.token as any,
76
+ deps.session as any,
77
+ deps.user as any,
78
+ deps.challenge as any,
79
+ ),
80
+ deps,
81
+ });
82
+
83
+ describe('AuthService', () => {
84
+ afterEach(() => jest.clearAllMocks());
85
+
86
+ describe('getAuthenticationPayload', () => {
87
+ it('cria sessão, gera accessToken e atualiza last_login_at', async () => {
88
+ const { service, deps } = makeService();
89
+ deps.session.create.mockResolvedValue({
90
+ token: 'refresh-token',
91
+ session: { id: 5, expires_at: new Date('2026-01-02') },
92
+ });
93
+ deps.token.createAccessToken.mockResolvedValue('access-token');
94
+
95
+ const result = await service.getAuthenticationPayload('pt-BR', 1, '1.2.3.4', 'UA', 'browser-1');
96
+
97
+ expect(deps.session.create).toHaveBeenCalledWith('pt-BR', 1, '1.2.3.4', 'UA', 'browser-1');
98
+ expect(deps.prisma.user.update).toHaveBeenCalledWith({
99
+ where: { id: 1 },
100
+ data: { last_login_at: expect.any(Date) },
101
+ });
102
+ expect(result).toEqual({
103
+ accessToken: 'access-token',
104
+ refreshToken: 'refresh-token',
105
+ session: { id: 5, expires_at: new Date('2026-01-02') },
106
+ });
107
+ });
108
+ });
109
+
110
+ describe('hasPendingPasswordReset / getRequiresPasswordResetByUserId', () => {
111
+ it('detecta reset pendente via credenciais do usuário', () => {
112
+ const { service } = makeService();
113
+ const withReset = {
114
+ user_credential: [
115
+ { type: 'password', requires_reset: true, revoked_at: null },
116
+ ],
117
+ };
118
+ const withoutReset = {
119
+ user_credential: [
120
+ { type: 'password', requires_reset: false, revoked_at: null },
121
+ ],
122
+ };
123
+ const revoked = {
124
+ user_credential: [
125
+ { type: 'password', requires_reset: true, revoked_at: new Date() },
126
+ ],
127
+ };
128
+
129
+ expect((service as any).hasPendingPasswordReset(withReset)).toBe(true);
130
+ expect((service as any).hasPendingPasswordReset(withoutReset)).toBe(false);
131
+ expect((service as any).hasPendingPasswordReset(revoked)).toBe(false);
132
+ expect((service as any).hasPendingPasswordReset(null)).toBe(false);
133
+ });
134
+
135
+ it('consulta o prisma para detectar reset pendente por userId', async () => {
136
+ const { service, deps } = makeService();
137
+ deps.prisma.user_credential.findFirst.mockResolvedValue({ id: 9 });
138
+ await expect(
139
+ (service as any).getRequiresPasswordResetByUserId(1),
140
+ ).resolves.toBe(true);
141
+
142
+ deps.prisma.user_credential.findFirst.mockResolvedValue(null);
143
+ await expect(
144
+ (service as any).getRequiresPasswordResetByUserId(1),
145
+ ).resolves.toBe(false);
146
+ });
147
+ });
148
+
149
+ describe('loginWithEmailAndPassword', () => {
150
+ it('lança quando o usuário não existe', async () => {
151
+ const { service, deps } = makeService();
152
+ deps.user.findUserByEmail.mockResolvedValue(null);
153
+
154
+ await expect(
155
+ service.loginWithEmailAndPassword({}, 'pt-BR', '1.1.1.1', 'UA', {
156
+ email: 'a@b.com',
157
+ password: 'x',
158
+ } as any),
159
+ ).rejects.toThrow(BadRequestException);
160
+ });
161
+
162
+ it('lança quando a senha é inválida', async () => {
163
+ const { service, deps } = makeService();
164
+ deps.user.findUserByEmail.mockResolvedValue({
165
+ id: 1,
166
+ user_credential: [{ type: 'password' }],
167
+ user_identifier: [],
168
+ });
169
+ deps.security.validatePassword.mockResolvedValue(false);
170
+
171
+ await expect(
172
+ service.loginWithEmailAndPassword({}, 'pt-BR', '1.1.1.1', 'UA', {
173
+ email: 'a@b.com',
174
+ password: 'wrong',
175
+ } as any),
176
+ ).rejects.toThrow(BadRequestException);
177
+ });
178
+
179
+ it('retorna requiresMfa quando existem métodos MFA verificados, enviando código para emails MFA', async () => {
180
+ const { service, deps } = makeService();
181
+ deps.user.findUserByEmail.mockResolvedValue({
182
+ id: 1,
183
+ user_credential: [{ type: 'password' }],
184
+ user_identifier: [{ type: 'email', value: 'a@b.com', verified_at: new Date() }],
185
+ });
186
+ deps.security.validatePassword.mockResolvedValue(true);
187
+ deps.prisma.user_mfa.findMany.mockResolvedValue([
188
+ {
189
+ id: 10,
190
+ type: 'email',
191
+ user_mfa_email: [{ email: 'mfa@b.com' }],
192
+ user_mfa_totp: [],
193
+ },
194
+ ]);
195
+ deps.token.createMfaChallengeToken.mockResolvedValue('mfa-token');
196
+
197
+ const result = await service.loginWithEmailAndPassword({}, 'pt-BR', '1.1.1.1', 'UA', {
198
+ email: 'a@b.com',
199
+ password: 'right',
200
+ } as any);
201
+
202
+ expect(deps.challenge.sendMfaCodeToMultipleEmails).toHaveBeenCalledWith(
203
+ 'pt-BR',
204
+ 1,
205
+ ['mfa@b.com'],
206
+ );
207
+ expect(result).toEqual({
208
+ requiresMfa: true,
209
+ mfaToken: 'mfa-token',
210
+ mfaMethods: [{ type: 'email', id: 10 }],
211
+ });
212
+ });
213
+
214
+ it('exige MFA por setting quando não há métodos MFA configurados', async () => {
215
+ const { service, deps } = makeService();
216
+ deps.user.findUserByEmail.mockResolvedValue({
217
+ id: 1,
218
+ user_credential: [{ type: 'password' }],
219
+ user_identifier: [{ type: 'email', value: 'a@b.com', verified_at: new Date() }],
220
+ });
221
+ deps.security.validatePassword.mockResolvedValue(true);
222
+ deps.prisma.user_mfa.findMany.mockResolvedValue([]);
223
+ deps.setting.getSettingValues.mockResolvedValue({
224
+ 'require-mfa': true,
225
+ 'require-email-verification': false,
226
+ });
227
+ deps.security.generateCode.mockReturnValue('123456');
228
+ deps.security.hashWithPepper.mockReturnValue('hash');
229
+ deps.prisma.user_identifier.findFirst.mockResolvedValue({ id: 3 });
230
+ deps.prisma.user_identifier_challenge.create.mockResolvedValue({ id: 4 });
231
+ deps.prisma.user_mfa.create.mockResolvedValue({
232
+ user_mfa_challenge: [{ id: 5 }],
233
+ });
234
+ deps.token.createAccessToken.mockResolvedValue('token-x');
235
+
236
+ const result = await service.loginWithEmailAndPassword({}, 'pt-BR', '1.1.1.1', 'UA', {
237
+ email: 'a@b.com',
238
+ password: 'right',
239
+ } as any);
240
+
241
+ expect(result).toEqual({ requiresMfa: true, token: 'token-x' });
242
+ });
243
+
244
+ it('exige verificação de email quando setting ativo e identifier não verificado', async () => {
245
+ const { service, deps } = makeService();
246
+ deps.user.findUserByEmail.mockResolvedValue({
247
+ id: 1,
248
+ user_credential: [{ type: 'password' }],
249
+ user_identifier: [{ type: 'email', value: 'a@b.com', verified_at: null }],
250
+ });
251
+ deps.security.validatePassword.mockResolvedValue(true);
252
+ deps.prisma.user_mfa.findMany.mockResolvedValue([]);
253
+ deps.setting.getSettingValues.mockResolvedValue({
254
+ 'require-mfa': false,
255
+ 'require-email-verification': true,
256
+ });
257
+ deps.security.generateCode.mockReturnValue('123456');
258
+ deps.security.hashWithPepper.mockReturnValue('hash');
259
+ deps.prisma.user_identifier.findFirst.mockResolvedValue({ id: 3 });
260
+ deps.prisma.user_identifier_challenge.create.mockResolvedValue({ id: 4 });
261
+ deps.token.createAccessToken.mockResolvedValue('token-y');
262
+
263
+ const result = await service.loginWithEmailAndPassword({}, 'pt-BR', '1.1.1.1', 'UA', {
264
+ email: 'a@b.com',
265
+ password: 'right',
266
+ } as any);
267
+
268
+ expect(result).toEqual({ requiresEmailVerification: true, token: 'token-y' });
269
+ });
270
+
271
+ it('efetua login completo quando nenhuma verificação adicional é exigida', async () => {
272
+ const { service, deps } = makeService();
273
+ const user = {
274
+ id: 1,
275
+ name: 'John',
276
+ user_credential: [{ type: 'password' }],
277
+ user_identifier: [{ type: 'email', value: 'a@b.com', verified_at: new Date() }],
278
+ };
279
+ deps.user.findUserByEmail.mockResolvedValue(user);
280
+ deps.security.validatePassword.mockResolvedValue(true);
281
+ deps.prisma.user_mfa.findMany.mockResolvedValue([]);
282
+ deps.setting.getSettingValues.mockResolvedValue({
283
+ 'require-mfa': false,
284
+ 'require-email-verification': false,
285
+ });
286
+ deps.prisma.user_identifier.findMany.mockResolvedValue([{ value: 'a@b.com' }]);
287
+ deps.session.create.mockResolvedValue({
288
+ token: 'refresh-1',
289
+ session: { id: 1, expires_at: new Date('2026-01-02') },
290
+ });
291
+ deps.token.createAccessToken.mockResolvedValue('access-1');
292
+
293
+ const result = await service.loginWithEmailAndPassword({}, 'pt-BR', '1.1.1.1', 'UA', {
294
+ email: 'a@b.com',
295
+ password: 'right',
296
+ } as any);
297
+
298
+ expect(deps.user.registerUserActivity).toHaveBeenCalledWith(1, 'login');
299
+ expect(deps.token.setRefreshTokenCookie).toHaveBeenCalled();
300
+ expect(result).toEqual({
301
+ accessToken: 'access-1',
302
+ refreshToken: 'refresh-1',
303
+ requiresPasswordReset: false,
304
+ });
305
+ });
306
+
307
+ it('lança quando o login não possui identificador de email', async () => {
308
+ const { service, deps } = makeService();
309
+ deps.user.findUserByEmail.mockResolvedValue({
310
+ id: 1,
311
+ user_credential: [{ type: 'password' }],
312
+ user_identifier: [],
313
+ });
314
+ deps.security.validatePassword.mockResolvedValue(true);
315
+ deps.prisma.user_mfa.findMany.mockResolvedValue([]);
316
+ deps.setting.getSettingValues.mockResolvedValue({
317
+ 'require-mfa': false,
318
+ 'require-email-verification': false,
319
+ });
320
+ deps.prisma.user_identifier.findMany.mockResolvedValue([]);
321
+
322
+ await expect(
323
+ service.loginWithEmailAndPassword({}, 'pt-BR', '1.1.1.1', 'UA', {
324
+ email: 'a@b.com',
325
+ password: 'right',
326
+ } as any),
327
+ ).rejects.toThrow(BadRequestException);
328
+ });
329
+ });
330
+
331
+ describe('verifyRoles / verifyUser', () => {
332
+ it('verifyRoles delega para prisma.role.findMany', async () => {
333
+ const { service, deps } = makeService();
334
+ deps.prisma.role.findMany.mockResolvedValue([{ slug: 'admin' }]);
335
+ await expect(service.verifyRoles('pt-BR', 1)).resolves.toEqual([{ slug: 'admin' }]);
336
+ });
337
+
338
+ it('verifyUser remove user_credential e adiciona requires_password_reset', async () => {
339
+ const { service, deps } = makeService();
340
+ deps.user.findUserById.mockResolvedValue({
341
+ id: 1,
342
+ name: 'John',
343
+ user_credential: [{ type: 'password', requires_reset: true, revoked_at: null }],
344
+ });
345
+
346
+ const result = await service.verifyUser('pt-BR', 1);
347
+
348
+ expect(result).not.toHaveProperty('user_credential');
349
+ expect(result.requires_password_reset).toBe(true);
350
+ });
351
+ });
352
+
353
+ describe('refreshAccessToken', () => {
354
+ it('lança Unauthorized quando não há refreshToken', async () => {
355
+ const { service } = makeService();
356
+ await expect(
357
+ service.refreshAccessToken('pt-BR', '', '1.1.1.1', 'UA'),
358
+ ).rejects.toThrow(UnauthorizedException);
359
+ });
360
+
361
+ it('renova o token com sucesso', async () => {
362
+ const { service, deps } = makeService();
363
+ deps.session.refresh.mockResolvedValue({
364
+ session: { id: 2, user_id: 9 },
365
+ token: 'new-refresh',
366
+ });
367
+ deps.user.findUserById.mockResolvedValue({ id: 9 });
368
+ deps.token.createAccessToken.mockResolvedValue('new-access');
369
+
370
+ const result = await service.refreshAccessToken('pt-BR', 'old-refresh', '1.1.1.1', 'UA');
371
+
372
+ expect(result).toEqual({
373
+ accessToken: 'new-access',
374
+ refreshToken: 'new-refresh',
375
+ session: { id: 2, user_id: 9 },
376
+ });
377
+ });
378
+ });
379
+
380
+ describe('forgotPassword / logout', () => {
381
+ it('registra atividade e dispara forgotEmail quando usuário existe', async () => {
382
+ const { service, deps } = makeService();
383
+ deps.user.findUserByEmail.mockResolvedValue({ id: 3 });
384
+
385
+ const result = await service.forgotPassword('pt-BR', { email: 'a@b.com', app: 'admin' } as any);
386
+
387
+ expect(deps.user.registerUserActivity).toHaveBeenCalledWith(3, 'forgotPassword');
388
+ expect(deps.challenge.forgotEmail).toHaveBeenCalledWith('pt-BR', 'a@b.com', 'admin');
389
+ expect(result).toEqual({ success: true });
390
+ });
391
+
392
+ it('não registra atividade quando usuário não existe, mas ainda dispara forgotEmail', async () => {
393
+ const { service, deps } = makeService();
394
+ deps.user.findUserByEmail.mockResolvedValue(null);
395
+
396
+ await service.forgotPassword('pt-BR', { email: 'a@b.com', app: 'admin' } as any);
397
+
398
+ expect(deps.user.registerUserActivity).not.toHaveBeenCalled();
399
+ expect(deps.challenge.forgotEmail).toHaveBeenCalled();
400
+ });
401
+
402
+ it('logout usa req.auth.sub quando presente e registra logout', async () => {
403
+ const { service, deps } = makeService();
404
+ deps.session.deleteRefreshTokenCookie.mockResolvedValue({ userId: 99 });
405
+
406
+ const result = await service.logout({}, { auth: { sub: 5 } }, 'token');
407
+
408
+ expect(deps.user.registerUserActivity).toHaveBeenCalledWith(5, 'logout');
409
+ expect(result).toEqual({ success: true });
410
+ });
411
+
412
+ it('logout usa userId retornado da sessão quando req.auth ausente', async () => {
413
+ const { service, deps } = makeService();
414
+ deps.session.deleteRefreshTokenCookie.mockResolvedValue({ userId: 77 });
415
+
416
+ await service.logout({}, {}, 'token');
417
+
418
+ expect(deps.user.registerUserActivity).toHaveBeenCalledWith(77, 'logout');
419
+ });
420
+
421
+ it('logout não registra atividade quando nenhum userId é resolvido', async () => {
422
+ const { service, deps } = makeService();
423
+ deps.session.deleteRefreshTokenCookie.mockResolvedValue(null);
424
+
425
+ await service.logout({}, {}, 'token');
426
+
427
+ expect(deps.user.registerUserActivity).not.toHaveBeenCalled();
428
+ });
429
+ });
430
+
431
+ describe('verifyMfaCode', () => {
432
+ it('lança quando usuário não é encontrado', async () => {
433
+ const { service, deps } = makeService();
434
+ deps.token.verifyMfaChallengeToken.mockResolvedValue({ userId: 1 });
435
+ deps.prisma.user.findUnique.mockResolvedValue(null);
436
+
437
+ await expect(
438
+ service.verifyMfaCode('pt-BR', 'mfa-token', '123456', '1.1.1.1', 'UA', 'totp'),
439
+ ).rejects.toThrow(BadRequestException);
440
+ });
441
+
442
+ it('valida com sucesso via totp e retorna tokens com requiresPasswordReset', async () => {
443
+ const { service, deps } = makeService();
444
+ deps.token.verifyMfaChallengeToken.mockResolvedValue({ userId: 1, email: 'a@b.com' });
445
+ deps.prisma.user.findUnique.mockResolvedValue({
446
+ id: 1,
447
+ name: 'John',
448
+ user_mfa: [
449
+ { type: 'totp', user_mfa_totp: [{ secret: 'BASE32SECRET' }] },
450
+ ],
451
+ user_recovery_code: [],
452
+ });
453
+ deps.setting.getSettingValues.mockResolvedValue({});
454
+ speakeasyVerify.mockReturnValue(true);
455
+ deps.session.create.mockResolvedValue({
456
+ token: 'refresh-2',
457
+ session: { id: 2, expires_at: new Date('2026-01-02') },
458
+ });
459
+ deps.token.createAccessToken.mockResolvedValue('access-2');
460
+ deps.prisma.user_credential.findFirst.mockResolvedValue({ id: 1 });
461
+
462
+ const result = await service.verifyMfaCode('pt-BR', 'mfa-token', '123456', '1.1.1.1', 'UA', 'totp');
463
+
464
+ expect(speakeasyVerify).toHaveBeenCalled();
465
+ expect(result).toEqual({
466
+ accessToken: 'access-2',
467
+ refreshToken: 'refresh-2',
468
+ session: { id: 2, expires_at: new Date('2026-01-02') },
469
+ requiresPasswordReset: true,
470
+ });
471
+ });
472
+
473
+ it('lança BadRequest quando o código totp é inválido', async () => {
474
+ const { service, deps } = makeService();
475
+ deps.token.verifyMfaChallengeToken.mockResolvedValue({ userId: 1, email: 'a@b.com' });
476
+ deps.prisma.user.findUnique.mockResolvedValue({
477
+ id: 1,
478
+ user_mfa: [{ type: 'totp', user_mfa_totp: [{ secret: 'SECRET' }] }],
479
+ user_recovery_code: [],
480
+ });
481
+ deps.setting.getSettingValues.mockResolvedValue({});
482
+ speakeasyVerify.mockReturnValue(false);
483
+
484
+ await expect(
485
+ service.verifyMfaCode('pt-BR', 'mfa-token', '000000', '1.1.1.1', 'UA', 'totp'),
486
+ ).rejects.toThrow(BadRequestException);
487
+ });
488
+
489
+ it('valida com sucesso via email', async () => {
490
+ const { service, deps } = makeService();
491
+ deps.token.verifyMfaChallengeToken.mockResolvedValue({ userId: 1, email: 'a@b.com' });
492
+ deps.prisma.user.findUnique.mockResolvedValue({
493
+ id: 1,
494
+ name: 'John',
495
+ user_mfa: [{ type: 'email' }],
496
+ user_recovery_code: [],
497
+ });
498
+ deps.setting.getSettingValues.mockResolvedValue({});
499
+ deps.challenge.verifyMfaEmailCode.mockResolvedValue(true);
500
+ deps.session.create.mockResolvedValue({
501
+ token: 'refresh-3',
502
+ session: { id: 3, expires_at: new Date('2026-01-02') },
503
+ });
504
+ deps.token.createAccessToken.mockResolvedValue('access-3');
505
+ deps.prisma.user_credential.findFirst.mockResolvedValue(null);
506
+
507
+ const result = await service.verifyMfaCode('pt-BR', 'mfa-token', '123456', '1.1.1.1', 'UA', 'email');
508
+
509
+ expect(deps.challenge.verifyMfaEmailCode).toHaveBeenCalledWith('pt-BR', 1, '123456');
510
+ expect(result.requiresPasswordReset).toBe(false);
511
+ });
512
+
513
+ it('valida com sucesso via recovery code e o consome (delete)', async () => {
514
+ const { service, deps } = makeService();
515
+ deps.token.verifyMfaChallengeToken.mockResolvedValue({ userId: 1, email: 'a@b.com' });
516
+ deps.prisma.user.findUnique.mockResolvedValue({
517
+ id: 1,
518
+ name: 'John',
519
+ user_mfa: [],
520
+ user_recovery_code: [{ id: 8, hash: 'hashed' }],
521
+ });
522
+ deps.setting.getSettingValues.mockResolvedValue({});
523
+ deps.security.verifyArgon2.mockResolvedValue(true);
524
+ deps.session.create.mockResolvedValue({
525
+ token: 'refresh-4',
526
+ session: { id: 4, expires_at: new Date('2026-01-02') },
527
+ });
528
+ deps.token.createAccessToken.mockResolvedValue('access-4');
529
+ deps.prisma.user_credential.findFirst.mockResolvedValue(null);
530
+
531
+ await service.verifyMfaCode('pt-BR', 'mfa-token', 'recovery-code', '1.1.1.1', 'UA', 'recovery');
532
+
533
+ expect(deps.prisma.user_recovery_code.delete).toHaveBeenCalledWith({ where: { id: 8 } });
534
+ });
535
+
536
+ it('lança BadRequest quando o recovery code é inválido', async () => {
537
+ const { service, deps } = makeService();
538
+ deps.token.verifyMfaChallengeToken.mockResolvedValue({ userId: 1, email: 'a@b.com' });
539
+ deps.prisma.user.findUnique.mockResolvedValue({
540
+ id: 1,
541
+ user_mfa: [],
542
+ user_recovery_code: [{ id: 8, hash: 'hashed' }],
543
+ });
544
+ deps.setting.getSettingValues.mockResolvedValue({});
545
+ deps.security.verifyArgon2.mockResolvedValue(false);
546
+
547
+ await expect(
548
+ service.verifyMfaCode('pt-BR', 'mfa-token', 'bad-code', '1.1.1.1', 'UA', 'recovery'),
549
+ ).rejects.toThrow(BadRequestException);
550
+ });
551
+
552
+ it('sem methodType tenta totp e cai para email quando totp falha', async () => {
553
+ const { service, deps } = makeService();
554
+ deps.token.verifyMfaChallengeToken.mockResolvedValue({ userId: 1, email: 'a@b.com' });
555
+ deps.prisma.user.findUnique.mockResolvedValue({
556
+ id: 1,
557
+ name: 'John',
558
+ user_mfa: [
559
+ { type: 'totp', user_mfa_totp: [{ secret: 'SECRET' }] },
560
+ { type: 'email' },
561
+ ],
562
+ user_recovery_code: [],
563
+ });
564
+ deps.setting.getSettingValues.mockResolvedValue({});
565
+ speakeasyVerify.mockReturnValue(false);
566
+ deps.challenge.verifyMfaEmailCode.mockResolvedValue(true);
567
+ deps.session.create.mockResolvedValue({
568
+ token: 'refresh-5',
569
+ session: { id: 5, expires_at: new Date('2026-01-02') },
570
+ });
571
+ deps.token.createAccessToken.mockResolvedValue('access-5');
572
+ deps.prisma.user_credential.findFirst.mockResolvedValue(null);
573
+
574
+ const result = await service.verifyMfaCode('pt-BR', 'mfa-token', '123456', '1.1.1.1', 'UA');
575
+
576
+ expect(deps.challenge.verifyMfaEmailCode).toHaveBeenCalled();
577
+ expect(result.accessToken).toBe('access-5');
578
+ });
579
+ });
580
+
581
+ describe('verifyMfaRecoveryCode', () => {
582
+ it('lança quando usuário não é encontrado', async () => {
583
+ const { service, deps } = makeService();
584
+ deps.token.verifyMfaChallengeToken.mockResolvedValue({ userId: 1 });
585
+ deps.prisma.user.findUnique.mockResolvedValue(null);
586
+
587
+ await expect(
588
+ service.verifyMfaRecoveryCode('pt-BR', 'mfa-token', 'code', '1.1.1.1', 'UA'),
589
+ ).rejects.toThrow(BadRequestException);
590
+ });
591
+
592
+ it('lança quando o código não confere com nenhum recovery code', async () => {
593
+ const { service, deps } = makeService();
594
+ deps.token.verifyMfaChallengeToken.mockResolvedValue({ userId: 1 });
595
+ deps.prisma.user.findUnique.mockResolvedValue({
596
+ id: 1,
597
+ user_recovery_code: [{ id: 1, hash: 'a' }],
598
+ });
599
+ deps.security.verifyArgon2.mockResolvedValue(false);
600
+
601
+ await expect(
602
+ service.verifyMfaRecoveryCode('pt-BR', 'mfa-token', 'bad', '1.1.1.1', 'UA'),
603
+ ).rejects.toThrow(BadRequestException);
604
+ });
605
+
606
+ it('consome o recovery code e retorna tokens quando válido', async () => {
607
+ const { service, deps } = makeService();
608
+ deps.token.verifyMfaChallengeToken.mockResolvedValue({ userId: 1 });
609
+ deps.prisma.user.findUnique.mockResolvedValue({
610
+ id: 1,
611
+ user_recovery_code: [{ id: 1, hash: 'a' }],
612
+ });
613
+ deps.security.verifyArgon2.mockResolvedValue(true);
614
+ deps.session.create.mockResolvedValue({
615
+ token: 'refresh-6',
616
+ session: { id: 6, expires_at: new Date('2026-01-02') },
617
+ });
618
+ deps.token.createAccessToken.mockResolvedValue('access-6');
619
+ deps.prisma.user_credential.findFirst.mockResolvedValue(null);
620
+
621
+ const result = await service.verifyMfaRecoveryCode('pt-BR', 'mfa-token', 'good', '1.1.1.1', 'UA');
622
+
623
+ expect(deps.prisma.user_recovery_code.delete).toHaveBeenCalledWith({ where: { id: 1 } });
624
+ expect(result.accessToken).toBe('access-6');
625
+ });
626
+ });
627
+
628
+ describe('resendMfaCode', () => {
629
+ it('lança quando não há mfa de email verificado', async () => {
630
+ const { service, deps } = makeService();
631
+ deps.token.verifyMfaChallengeToken.mockResolvedValue({ userId: 1 });
632
+ deps.prisma.user_mfa.findMany.mockResolvedValue([]);
633
+
634
+ await expect(service.resendMfaCode('pt-BR', 'mfa-token')).rejects.toThrow(
635
+ BadRequestException,
636
+ );
637
+ });
638
+
639
+ it('lança quando o método de email não possui user_mfa_email', async () => {
640
+ const { service, deps } = makeService();
641
+ deps.token.verifyMfaChallengeToken.mockResolvedValue({ userId: 1 });
642
+ deps.prisma.user_mfa.findMany.mockResolvedValue([{ user_mfa_email: [] }]);
643
+
644
+ await expect(service.resendMfaCode('pt-BR', 'mfa-token')).rejects.toThrow(
645
+ BadRequestException,
646
+ );
647
+ });
648
+
649
+ it('reenvia o código quando há mfa de email configurado', async () => {
650
+ const { service, deps } = makeService();
651
+ deps.token.verifyMfaChallengeToken.mockResolvedValue({ userId: 1 });
652
+ deps.prisma.user_mfa.findMany.mockResolvedValue([
653
+ { user_mfa_email: [{ email: 'a@b.com' }] },
654
+ ]);
655
+
656
+ const result = await service.resendMfaCode('pt-BR', 'mfa-token');
657
+
658
+ expect(deps.challenge.sendMfaCodeByEmail).toHaveBeenCalledWith('pt-BR', 1, 'a@b.com');
659
+ expect(result).toEqual({ success: true, hasEmailMfa: true });
660
+ });
661
+ });
662
+
663
+ describe('requiresMfaForLogin / requiresEmailVerificationForLogin', () => {
664
+ const settings = () => ({
665
+ 'mfa-email-code-length': 6,
666
+ 'mfa-challenge-expiration-minutes': 15,
667
+ });
668
+
669
+ it('lança NotFound quando o identifier de email não existe (MFA)', async () => {
670
+ const { service, deps } = makeService();
671
+ deps.setting.getSettingValues.mockResolvedValue(settings());
672
+ deps.security.generateCode.mockReturnValue('123456');
673
+ deps.security.hashWithPepper.mockReturnValue('hash');
674
+ deps.prisma.user_identifier.findFirst.mockResolvedValue(null);
675
+
676
+ await expect(
677
+ service.requiresMfaForLogin('pt-BR', 'a@b.com', { id: 1, name: 'John' }),
678
+ ).rejects.toThrow(NotFoundException);
679
+ });
680
+
681
+ it('cria challenge, mfa e envia email, retornando token', async () => {
682
+ const { service, deps } = makeService();
683
+ deps.setting.getSettingValues.mockResolvedValue(settings());
684
+ deps.security.generateCode.mockReturnValue('123456');
685
+ deps.security.hashWithPepper.mockReturnValue('hash');
686
+ deps.prisma.user_identifier.findFirst.mockResolvedValue({ id: 2 });
687
+ deps.prisma.user_identifier_challenge.create.mockResolvedValue({ id: 3 });
688
+ deps.prisma.user_mfa.create.mockResolvedValue({ user_mfa_challenge: [{ id: 4 }] });
689
+ deps.token.createAccessToken.mockResolvedValue('mfa-tok');
690
+
691
+ const result = await service.requiresMfaForLogin('pt-BR', 'a@b.com', { id: 1, name: 'John' });
692
+
693
+ expect(deps.mail.sendTemplatedMail).toHaveBeenCalled();
694
+ expect(result).toEqual({ requiresMfa: true, token: 'mfa-tok' });
695
+ });
696
+
697
+ it('lança NotFound quando o identifier de email não existe (verificação de email)', async () => {
698
+ const { service, deps } = makeService();
699
+ deps.setting.getSettingValues.mockResolvedValue(settings());
700
+ deps.security.generateCode.mockReturnValue('123456');
701
+ deps.security.hashWithPepper.mockReturnValue('hash');
702
+ deps.prisma.user_identifier.findFirst.mockResolvedValue(null);
703
+
704
+ await expect(
705
+ service.requiresEmailVerificationForLogin('pt-BR', 'a@b.com', { id: 1, name: 'John' }),
706
+ ).rejects.toThrow(NotFoundException);
707
+ });
708
+
709
+ it('cria challenge e envia email de verificação, retornando token', async () => {
710
+ const { service, deps } = makeService();
711
+ deps.setting.getSettingValues.mockResolvedValue(settings());
712
+ deps.security.generateCode.mockReturnValue('123456');
713
+ deps.security.hashWithPepper.mockReturnValue('hash');
714
+ deps.prisma.user_identifier.findFirst.mockResolvedValue({ id: 2 });
715
+ deps.prisma.user_identifier_challenge.create.mockResolvedValue({ id: 3 });
716
+ deps.token.createAccessToken.mockResolvedValue('verify-tok');
717
+
718
+ const result = await service.requiresEmailVerificationForLogin('pt-BR', 'a@b.com', {
719
+ id: 1,
720
+ name: 'John',
721
+ });
722
+
723
+ expect(result).toEqual({ requiresEmailVerification: true, token: 'verify-tok' });
724
+ });
725
+ });
726
+
727
+ describe('emailVerificationLoginResend', () => {
728
+ it('reenvia a verificação usando o payload do token válido', async () => {
729
+ const { service, deps } = makeService();
730
+ deps.token.verify.mockResolvedValue({ challengeIdentifierId: 1, email: 'a@b.com' });
731
+ deps.prisma.user_identifier_challenge.findUnique.mockResolvedValue({
732
+ user_identifier: { user_id: 9 },
733
+ });
734
+ deps.user.findUserById.mockResolvedValue({ id: 9, name: 'John' });
735
+ deps.setting.getSettingValues.mockResolvedValue({
736
+ 'mfa-email-code-length': 6,
737
+ 'mfa-challenge-expiration-minutes': 15,
738
+ });
739
+ deps.security.generateCode.mockReturnValue('123456');
740
+ deps.security.hashWithPepper.mockReturnValue('hash');
741
+ deps.prisma.user_identifier.findFirst.mockResolvedValue({ id: 2 });
742
+ deps.prisma.user_identifier_challenge.create.mockResolvedValue({ id: 3 });
743
+ deps.token.createAccessToken.mockResolvedValue('resend-tok');
744
+
745
+ const result = await service.emailVerificationLoginResend('pt-BR', 'tok');
746
+
747
+ expect(result).toEqual({ requiresEmailVerification: true, token: 'resend-tok' });
748
+ });
749
+
750
+ it('lança quando o challenge do token não existe', async () => {
751
+ const { service, deps } = makeService();
752
+ deps.token.verify.mockResolvedValue({ challengeIdentifierId: 1, email: 'a@b.com' });
753
+ deps.prisma.user_identifier_challenge.findUnique.mockResolvedValue(null);
754
+
755
+ await expect(service.emailVerificationLoginResend('pt-BR', 'tok')).rejects.toThrow(
756
+ NotFoundException,
757
+ );
758
+ });
759
+
760
+ it('gera um novo token e tenta novamente quando o token expirou', async () => {
761
+ const { service, deps } = makeService();
762
+ deps.token.verify
763
+ .mockRejectedValueOnce(Object.assign(new Error('jwt expired'), { name: 'TokenExpiredError' }))
764
+ .mockResolvedValueOnce({ challengeIdentifierId: 1, email: 'a@b.com' });
765
+ deps.token.decodeExpiredToken.mockResolvedValue({
766
+ challengeIdentifierId: 1,
767
+ email: 'a@b.com',
768
+ });
769
+ deps.token.createAccessToken.mockResolvedValueOnce('new-tok').mockResolvedValueOnce('resend-tok-2');
770
+ deps.prisma.user_identifier_challenge.findUnique.mockResolvedValue({
771
+ user_identifier: { user_id: 9 },
772
+ });
773
+ deps.user.findUserById.mockResolvedValue({ id: 9, name: 'John' });
774
+ deps.setting.getSettingValues.mockResolvedValue({
775
+ 'mfa-email-code-length': 6,
776
+ 'mfa-challenge-expiration-minutes': 15,
777
+ });
778
+ deps.security.generateCode.mockReturnValue('123456');
779
+ deps.security.hashWithPepper.mockReturnValue('hash');
780
+ deps.prisma.user_identifier.findFirst.mockResolvedValue({ id: 2 });
781
+ deps.prisma.user_identifier_challenge.create.mockResolvedValue({ id: 3 });
782
+
783
+ const result = await service.emailVerificationLoginResend('pt-BR', 'expired-tok');
784
+
785
+ expect(deps.token.decodeExpiredToken).toHaveBeenCalledWith('expired-tok');
786
+ expect(result).toEqual({ requiresEmailVerification: true, token: 'resend-tok-2' });
787
+ });
788
+
789
+ it('propaga outros erros não relacionados a expiração', async () => {
790
+ const { service, deps } = makeService();
791
+ deps.token.verify.mockRejectedValue(new Error('unexpected'));
792
+
793
+ await expect(service.emailVerificationLoginResend('pt-BR', 'tok')).rejects.toThrow(
794
+ 'unexpected',
795
+ );
796
+ });
797
+ });
798
+
799
+ describe('emailVerificationLogin', () => {
800
+ it('lança quando o challenge não existe', async () => {
801
+ const { service, deps } = makeService();
802
+ deps.token.verify.mockResolvedValue({ challengeIdentifierId: 1 });
803
+ deps.prisma.user_identifier_challenge.findUnique.mockResolvedValue(null);
804
+
805
+ await expect(
806
+ service.emailVerificationLogin('pt-BR', 'tok', '123456', '1.1.1.1', 'UA', {}),
807
+ ).rejects.toThrow(NotFoundException);
808
+ });
809
+
810
+ it('lança quando o código informado é inválido', async () => {
811
+ const { service, deps } = makeService();
812
+ deps.token.verify.mockResolvedValue({ challengeIdentifierId: 1 });
813
+ deps.prisma.user_identifier_challenge.findUnique.mockResolvedValue({
814
+ hash: 'expected-hash',
815
+ user_identifier_id: 2,
816
+ user_identifier: { user_id: 9 },
817
+ });
818
+ deps.security.hashWithPepper.mockReturnValue('other-hash');
819
+
820
+ await expect(
821
+ service.emailVerificationLogin('pt-BR', 'tok', '000000', '1.1.1.1', 'UA', {}),
822
+ ).rejects.toThrow(BadRequestException);
823
+ });
824
+
825
+ it('verifica o código, marca como verificado e efetua login', async () => {
826
+ const { service, deps } = makeService();
827
+ deps.token.verify.mockResolvedValue({ challengeIdentifierId: 1 });
828
+ deps.prisma.user_identifier_challenge.findUnique.mockResolvedValue({
829
+ hash: 'matching-hash',
830
+ user_identifier_id: 2,
831
+ user_identifier: { user_id: 9 },
832
+ });
833
+ deps.security.hashWithPepper.mockReturnValue('matching-hash');
834
+ deps.prisma.$transaction.mockImplementation(async (cb: any) =>
835
+ cb({
836
+ user_identifier_challenge: { update: jest.fn() },
837
+ user_identifier: { update: jest.fn() },
838
+ }),
839
+ );
840
+ const user = {
841
+ id: 9,
842
+ name: 'John',
843
+ user_identifier: [{ type: 'email', value: 'a@b.com' }],
844
+ };
845
+ deps.user.findUserById.mockResolvedValue(user);
846
+ deps.prisma.user_identifier.findMany.mockResolvedValue([{ value: 'a@b.com' }]);
847
+ deps.session.create.mockResolvedValue({
848
+ token: 'refresh-7',
849
+ session: { id: 7, expires_at: new Date('2026-01-02') },
850
+ });
851
+ deps.token.createAccessToken.mockResolvedValue('access-7');
852
+ deps.prisma.user_credential.findFirst.mockResolvedValue(null);
853
+
854
+ const result = await service.emailVerificationLogin('pt-BR', 'tok', '123456', '1.1.1.1', 'UA', {});
855
+
856
+ expect(deps.prisma.user_identifier_challenge.update).toHaveBeenCalledWith(
857
+ expect.objectContaining({ data: { attempts: { increment: 1 } } }),
858
+ );
859
+ expect(result.accessToken).toBe('access-7');
860
+ });
861
+ });
862
+
863
+ describe('forgotResetPassword', () => {
864
+ it('atualiza a senha, envia confirmação e retorna nova sessão', async () => {
865
+ const { service, deps } = makeService();
866
+ deps.challenge.verifyCodePassword.mockResolvedValue({
867
+ user_identifier: {
868
+ user_id: 1,
869
+ value: 'a@b.com',
870
+ id: 2,
871
+ user: { name: 'John' },
872
+ },
873
+ });
874
+ deps.security.hashArgon2.mockResolvedValue('new-hash');
875
+ deps.session.create.mockResolvedValue({
876
+ token: 'refresh-8',
877
+ session: { id: 8, expires_at: new Date('2026-01-02') },
878
+ });
879
+ deps.token.createAccessToken.mockResolvedValue('access-8');
880
+
881
+ const result = await service.forgotResetPassword('pt-BR', 'newpass', 'code', '1.1.1.1', 'UA');
882
+
883
+ expect(deps.prisma.user_credential.updateMany).toHaveBeenCalledWith({
884
+ where: { type: 'password', user_id: 1 },
885
+ data: { hash: 'new-hash', requires_reset: false },
886
+ });
887
+ expect(deps.user.registerUserActivity).toHaveBeenCalledWith(2, 'resetPassword');
888
+ expect(result.accessToken).toBe('access-8');
889
+ });
890
+ });
891
+ });