@hed-hog/core 0.0.371 → 0.0.374

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 (107) hide show
  1. package/README.md +83 -1
  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 +57 -9
  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/messages/en.json +35 -0
  57. package/hedhog/frontend/messages/pt.json +35 -0
  58. package/hedhog/table/user_account.yaml +1 -0
  59. package/package.json +4 -4
  60. package/src/access-log/access-log.service.spec.ts +235 -0
  61. package/src/ai-instruction/ai-prompt-builder.service.spec.ts +250 -0
  62. package/src/auth/auth.service.spec.ts +891 -0
  63. package/src/auth/guards/auth.guard.spec.ts +177 -0
  64. package/src/challenge/challenge.service.spec.ts +334 -0
  65. package/src/dashboard/dashboard-core/dashboard-core.service.spec.ts +193 -0
  66. package/src/file/image-optimization.service.spec.ts +283 -0
  67. package/src/file/provider/provider.factory.spec.ts +111 -0
  68. package/src/install/install.service.spec.ts +139 -0
  69. package/src/integration/services/event-subscriber.registry.spec.ts +143 -0
  70. package/src/integration/services/inbox.service.spec.ts +183 -0
  71. package/src/integration/services/integration-link.service.spec.ts +217 -0
  72. package/src/integration/services/outbox.service.spec.ts +290 -0
  73. package/src/integration-profile/integration-credential-crypto.service.spec.ts +289 -0
  74. package/src/integration-profile/integration-profile.secrets.ts +4 -0
  75. package/src/integration-profile/integration-profile.utils.spec.ts +332 -0
  76. package/src/integration-profile/integration-profile.utils.ts +14 -1
  77. package/src/locale/locale.service.spec.ts +78 -0
  78. package/src/mail/mail.service.spec.ts +257 -0
  79. package/src/mcp/guards/mcp-auth.guard.spec.ts +87 -0
  80. package/src/mcp/guards/mcp-permission.guard.spec.ts +64 -0
  81. package/src/mcp/mcp-api-key.service.spec.ts +125 -0
  82. package/src/mcp/mcp-registry.service.spec.ts +96 -0
  83. package/src/mcp/mcp-server.service.spec.ts +187 -0
  84. package/src/menu/menu.service.spec.ts +76 -0
  85. package/src/notification/notification.service.spec.ts +297 -0
  86. package/src/oauth/oauth.controller.ts +53 -10
  87. package/src/oauth/oauth.module.ts +4 -0
  88. package/src/oauth/oauth.service.spec.ts +332 -0
  89. package/src/oauth/oauth.service.ts +156 -1
  90. package/src/oauth/providers/apple.provider.ts +175 -0
  91. package/src/oauth/providers/facebook.provider.ts +3 -2
  92. package/src/oauth/providers/google.provider.ts +3 -2
  93. package/src/oauth/providers/linkedin.provider.ts +96 -0
  94. package/src/oauth/providers/microsoft-entra-id.provider.ts +1 -1
  95. package/src/oauth/providers/microsoft.provider.ts +3 -2
  96. package/src/pdf/pdf.service.ts +20 -2
  97. package/src/role/guards/role.guard.spec.ts +157 -0
  98. package/src/role/role.service.spec.ts +179 -0
  99. package/src/session/session.service.spec.ts +376 -0
  100. package/src/setting/setting.service.spec.ts +316 -0
  101. package/src/setting/setting.service.ts +4 -1
  102. package/src/task/task.service.spec.ts +47 -0
  103. package/src/token/token.service.spec.ts +351 -0
  104. package/src/user/user.service.spec.ts +256 -0
  105. package/src/utils/locale-context.spec.ts +22 -0
  106. package/src/utils/webauthn-origin.spec.ts +73 -0
  107. package/src/webhook-integration/integration-event-catalog.service.spec.ts +149 -0
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import { UnauthorizedException } from '@nestjs/common';
3
+ import { IS_PUBLIC_KEY, WITH_NO_ROLE, WITH_ROLE } from '@hed-hog/api';
4
+ import { AuthGuard } from './auth.guard';
5
+
6
+ const makeContext = (request: any) => ({
7
+ getHandler: () => 'handler',
8
+ getClass: () => 'class',
9
+ switchToHttp: () => ({ getRequest: () => request }),
10
+ });
11
+
12
+ const makeDeps = () => {
13
+ const token = { verify: jest.fn() };
14
+ const reflector = { getAllAndOverride: jest.fn() };
15
+ const prisma = { mcp_api_key: { findFirst: jest.fn(), update: jest.fn() } };
16
+ const security = { hashWithPepper: jest.fn().mockReturnValue('hash') };
17
+ return { token, reflector, prisma, security };
18
+ };
19
+
20
+ const makeService = (deps = makeDeps()) => ({
21
+ guard: new AuthGuard(
22
+ deps.token as any,
23
+ deps.reflector as any,
24
+ deps.prisma as any,
25
+ deps.security as any,
26
+ ),
27
+ deps,
28
+ });
29
+
30
+ const setMeta = (
31
+ reflector: any,
32
+ meta: { public?: boolean; withRole?: boolean; withNoRole?: boolean },
33
+ ) => {
34
+ reflector.getAllAndOverride.mockImplementation((key: any) => {
35
+ if (key === IS_PUBLIC_KEY) return meta.public;
36
+ if (key === WITH_ROLE) return meta.withRole;
37
+ if (key === WITH_NO_ROLE) return meta.withNoRole;
38
+ return undefined;
39
+ });
40
+ };
41
+
42
+ describe('AuthGuard', () => {
43
+ afterEach(() => jest.clearAllMocks());
44
+
45
+ describe('sem token', () => {
46
+ it('permite rota pública', async () => {
47
+ const { guard, deps } = makeService();
48
+ setMeta(deps.reflector, { public: true });
49
+ const ctx = makeContext({ headers: {} });
50
+ await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
51
+ });
52
+
53
+ it('nega quando withRole exige autenticação', async () => {
54
+ const { guard, deps } = makeService();
55
+ setMeta(deps.reflector, { withRole: true });
56
+ const ctx = makeContext({ headers: {}, method: 'GET', url: '/x' });
57
+ await expect(guard.canActivate(ctx as any)).rejects.toThrow(
58
+ UnauthorizedException,
59
+ );
60
+ });
61
+
62
+ it('nega quando withNoRole exige autenticação', async () => {
63
+ const { guard, deps } = makeService();
64
+ setMeta(deps.reflector, { withNoRole: true });
65
+ const ctx = makeContext({ headers: {}, method: 'GET', url: '/x' });
66
+ await expect(guard.canActivate(ctx as any)).rejects.toThrow(
67
+ UnauthorizedException,
68
+ );
69
+ });
70
+
71
+ it('nega por padrão sem decorator', async () => {
72
+ const { guard, deps } = makeService();
73
+ setMeta(deps.reflector, {});
74
+ const ctx = makeContext({ headers: {}, method: 'GET', url: '/x' });
75
+ await expect(guard.canActivate(ctx as any)).rejects.toThrow(
76
+ UnauthorizedException,
77
+ );
78
+ });
79
+ });
80
+
81
+ describe('com token Bearer', () => {
82
+ it('anexa payload quando verify resolve', async () => {
83
+ const { guard, deps } = makeService();
84
+ setMeta(deps.reflector, {});
85
+ deps.token.verify.mockResolvedValue({ sub: 5 });
86
+ const request: any = { headers: { authorization: 'Bearer tok' } };
87
+ await expect(guard.canActivate(makeContext(request) as any)).resolves.toBe(
88
+ true,
89
+ );
90
+ expect(request.auth).toEqual({ sub: 5 });
91
+ });
92
+
93
+ it('permite quando verify falha mas rota é pública', async () => {
94
+ const { guard, deps } = makeService();
95
+ setMeta(deps.reflector, { public: true });
96
+ deps.token.verify.mockRejectedValue(new Error('bad'));
97
+ const request: any = { headers: { authorization: 'Bearer tok' } };
98
+ await expect(guard.canActivate(makeContext(request) as any)).resolves.toBe(
99
+ true,
100
+ );
101
+ });
102
+
103
+ it('nega quando verify falha e rota não é pública', async () => {
104
+ const { guard, deps } = makeService();
105
+ setMeta(deps.reflector, {});
106
+ deps.token.verify.mockRejectedValue(new Error('invalid token'));
107
+ const request: any = {
108
+ headers: { authorization: 'Bearer tok' },
109
+ method: 'GET',
110
+ url: '/x',
111
+ };
112
+ await expect(guard.canActivate(makeContext(request) as any)).rejects.toThrow(
113
+ UnauthorizedException,
114
+ );
115
+ });
116
+ });
117
+
118
+ describe('api key (hedhog_api_)', () => {
119
+ it('autentica com api key válida', async () => {
120
+ const { guard, deps } = makeService();
121
+ setMeta(deps.reflector, {});
122
+ deps.prisma.mcp_api_key.findFirst.mockResolvedValue({
123
+ id: 1,
124
+ user_id: 9,
125
+ expires_at: null,
126
+ });
127
+ deps.prisma.mcp_api_key.update.mockResolvedValue({});
128
+ const request: any = {
129
+ headers: { authorization: 'Bearer hedhog_api_secret' },
130
+ };
131
+ await expect(guard.canActivate(makeContext(request) as any)).resolves.toBe(
132
+ true,
133
+ );
134
+ expect(request.auth).toEqual({ sub: 9, sessionId: null });
135
+ });
136
+
137
+ it('nega api key inexistente', async () => {
138
+ const { guard, deps } = makeService();
139
+ setMeta(deps.reflector, {});
140
+ deps.prisma.mcp_api_key.findFirst.mockResolvedValue(null);
141
+ const request: any = {
142
+ headers: { authorization: 'Bearer hedhog_api_x' },
143
+ };
144
+ await expect(guard.canActivate(makeContext(request) as any)).rejects.toThrow(
145
+ 'Invalid API key',
146
+ );
147
+ });
148
+
149
+ it('nega api key expirada', async () => {
150
+ const { guard, deps } = makeService();
151
+ setMeta(deps.reflector, {});
152
+ deps.prisma.mcp_api_key.findFirst.mockResolvedValue({
153
+ id: 1,
154
+ user_id: 9,
155
+ expires_at: new Date(Date.now() - 1000),
156
+ });
157
+ const request: any = {
158
+ headers: { authorization: 'Bearer hedhog_api_x' },
159
+ };
160
+ await expect(guard.canActivate(makeContext(request) as any)).rejects.toThrow(
161
+ 'API key expired',
162
+ );
163
+ });
164
+
165
+ it('api key inválida em rota pública retorna true', async () => {
166
+ const { guard, deps } = makeService();
167
+ setMeta(deps.reflector, { public: true });
168
+ deps.prisma.mcp_api_key.findFirst.mockResolvedValue(null);
169
+ const request: any = {
170
+ headers: { authorization: 'Bearer hedhog_api_x' },
171
+ };
172
+ await expect(guard.canActivate(makeContext(request) as any)).resolves.toBe(
173
+ true,
174
+ );
175
+ });
176
+ });
177
+ });
@@ -0,0 +1,334 @@
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ beforeEach,
6
+ afterEach,
7
+ jest,
8
+ } from '@jest/globals';
9
+ import { BadRequestException, NotFoundException } from '@nestjs/common';
10
+ import { ChallengeService } from './challenge.service';
11
+
12
+ const makeDeps = () => {
13
+ const prisma = {
14
+ user: { findUnique: jest.fn() },
15
+ user_mfa: { create: jest.fn(), findFirst: jest.fn() },
16
+ user_mfa_challenge: {
17
+ create: jest.fn(),
18
+ findFirst: jest.fn(),
19
+ delete: jest.fn(),
20
+ },
21
+ user_identifier: { findFirst: jest.fn() },
22
+ user_identifier_challenge: {
23
+ count: jest.fn(),
24
+ create: jest.fn(),
25
+ findFirst: jest.fn(),
26
+ },
27
+ };
28
+ const security = {
29
+ hashWithPepper: jest.fn((v: string) => `hash(${v})`),
30
+ };
31
+ const setting = {
32
+ getSettingValues: jest.fn().mockResolvedValue({}),
33
+ };
34
+ const token = {
35
+ createOpaqueToken: jest.fn().mockResolvedValue('tok'),
36
+ };
37
+ const mail = {
38
+ sendTemplatedMail: jest.fn().mockResolvedValue(undefined),
39
+ };
40
+ return { prisma, security, setting, token, mail };
41
+ };
42
+
43
+ const makeService = (deps = makeDeps()) => ({
44
+ service: new ChallengeService(
45
+ deps.prisma as any,
46
+ deps.security as any,
47
+ deps.setting as any,
48
+ deps.token as any,
49
+ deps.mail as any,
50
+ ),
51
+ deps,
52
+ });
53
+
54
+ describe('ChallengeService', () => {
55
+ let getRandomValuesSpy: any;
56
+
57
+ beforeEach(() => {
58
+ // Estabiliza a geração de código: cada byte = 7 → dígito '7'.
59
+ getRandomValuesSpy = jest
60
+ .spyOn(globalThis.crypto, 'getRandomValues')
61
+ .mockImplementation((arr: any) => {
62
+ arr.fill(7);
63
+ return arr;
64
+ });
65
+ jest.useFakeTimers().setSystemTime(new Date('2026-01-15T12:00:00Z'));
66
+ });
67
+
68
+ afterEach(() => {
69
+ getRandomValuesSpy.mockRestore();
70
+ jest.useRealTimers();
71
+ jest.clearAllMocks();
72
+ });
73
+
74
+ describe('resolveAppUrl', () => {
75
+ const call = (settings: any, app?: string) =>
76
+ (makeService().service as any).resolveAppUrl(settings, app);
77
+
78
+ it('retorna a URL do app correspondente (key=value)', () => {
79
+ const settings = {
80
+ 'app-urls': ['admin=https://admin.com', 'training = https://train.com'],
81
+ url: 'https://default.com',
82
+ };
83
+ expect(call(settings, 'admin')).toBe('https://admin.com');
84
+ });
85
+
86
+ it('faz trim de chave e valor', () => {
87
+ const settings = {
88
+ 'app-urls': ['training = https://train.com'],
89
+ url: 'https://default.com',
90
+ };
91
+ expect(call(settings, 'training')).toBe('https://train.com');
92
+ });
93
+
94
+ it('cai para settings.url quando app não encontrado', () => {
95
+ const settings = {
96
+ 'app-urls': ['admin=https://admin.com'],
97
+ url: 'https://default.com',
98
+ };
99
+ expect(call(settings, 'unknown')).toBe('https://default.com');
100
+ });
101
+
102
+ it('cai para settings.url quando app não informado', () => {
103
+ const settings = { 'app-urls': [], url: 'https://default.com' };
104
+ expect(call(settings)).toBe('https://default.com');
105
+ });
106
+
107
+ it('trata app-urls não-array como vazio', () => {
108
+ const settings = { 'app-urls': 'x', url: 'https://default.com' };
109
+ expect(call(settings, 'admin')).toBe('https://default.com');
110
+ });
111
+ });
112
+
113
+ describe('forgotEmail', () => {
114
+ it('lança BadRequest (rate limit) quando há 3+ challenges abertos', async () => {
115
+ const { service, deps } = makeService();
116
+ deps.prisma.user_identifier_challenge.count.mockResolvedValue(3);
117
+ await expect(
118
+ service.forgotEmail('en', 'a@b.com'),
119
+ ).rejects.toThrow(BadRequestException);
120
+ expect(deps.prisma.user_identifier.findFirst).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('lança BadRequest quando usuário não encontrado', async () => {
124
+ const { service, deps } = makeService();
125
+ deps.prisma.user_identifier_challenge.count.mockResolvedValue(0);
126
+ deps.prisma.user_identifier.findFirst.mockResolvedValue(null);
127
+ await expect(
128
+ service.forgotEmail('en', 'a@b.com'),
129
+ ).rejects.toThrow(BadRequestException);
130
+ });
131
+
132
+ it('cria challenge e envia email com link base64', async () => {
133
+ const { service, deps } = makeService();
134
+ deps.prisma.user_identifier_challenge.count.mockResolvedValue(0);
135
+ deps.prisma.user_identifier.findFirst.mockResolvedValue({
136
+ id: 10,
137
+ user: { name: 'Bob' },
138
+ });
139
+ deps.setting.getSettingValues.mockResolvedValue({
140
+ 'mfa-challenge-expiration-minutes': 15,
141
+ url: 'https://app.com',
142
+ 'app-urls': [],
143
+ });
144
+ deps.prisma.user_identifier_challenge.create.mockResolvedValue({ id: 1 });
145
+
146
+ const result = await service.forgotEmail('en', 'a@b.com');
147
+
148
+ expect(result).toEqual({ id: 1 });
149
+ const base64 = Buffer.from('tok').toString('base64');
150
+ expect(deps.mail.sendTemplatedMail).toHaveBeenCalledWith(
151
+ 'en',
152
+ expect.objectContaining({
153
+ email: 'a@b.com',
154
+ slug: 'auth-forget-password',
155
+ variables: expect.objectContaining({
156
+ link: `https://app.com/reset-password?token=${base64}`,
157
+ name: 'Bob',
158
+ }),
159
+ }),
160
+ );
161
+ });
162
+ });
163
+
164
+ describe('verifyCodePassword', () => {
165
+ it('retorna challenge quando válido', async () => {
166
+ const { service, deps } = makeService();
167
+ const challenge = { id: 1, user_identifier: { user: { id: 2 } } };
168
+ deps.prisma.user_identifier_challenge.findFirst.mockResolvedValue(
169
+ challenge,
170
+ );
171
+ await expect(
172
+ service.verifyCodePassword('en', '123456'),
173
+ ).resolves.toBe(challenge);
174
+ expect(deps.security.hashWithPepper).toHaveBeenCalledWith('123456');
175
+ });
176
+
177
+ it('lança BadRequest quando challenge inválido/expirado', async () => {
178
+ const { service, deps } = makeService();
179
+ deps.prisma.user_identifier_challenge.findFirst.mockResolvedValue(null);
180
+ await expect(
181
+ service.verifyCodePassword('en', '000000'),
182
+ ).rejects.toThrow(BadRequestException);
183
+ });
184
+ });
185
+
186
+ describe('verifyMfaEmailCode', () => {
187
+ it('retorna false quando challenge não encontrado', async () => {
188
+ const { service, deps } = makeService();
189
+ deps.prisma.user_mfa_challenge.findFirst.mockResolvedValue(null);
190
+ await expect(
191
+ service.verifyMfaEmailCode('en', 1, '123456'),
192
+ ).resolves.toBe(false);
193
+ expect(deps.prisma.user_mfa_challenge.delete).not.toHaveBeenCalled();
194
+ });
195
+
196
+ it('deleta o challenge e retorna true quando válido', async () => {
197
+ const { service, deps } = makeService();
198
+ deps.prisma.user_mfa_challenge.findFirst.mockResolvedValue({ id: 9 });
199
+ deps.prisma.user_mfa_challenge.delete.mockResolvedValue({ id: 9 });
200
+ await expect(
201
+ service.verifyMfaEmailCode('en', 1, '123456'),
202
+ ).resolves.toBe(true);
203
+ expect(deps.prisma.user_mfa_challenge.delete).toHaveBeenCalledWith({
204
+ where: { id: 9 },
205
+ });
206
+ });
207
+ });
208
+
209
+ describe('verifyCode', () => {
210
+ it('lança BadRequest quando hash não bate com providedHash', async () => {
211
+ const { service } = makeService();
212
+ // hashWithPepper('123456') => 'hash(123456)' !== 'other'
213
+ await expect(
214
+ service.verifyCode('en', 1, '123456', 'other'),
215
+ ).rejects.toThrow(BadRequestException);
216
+ });
217
+
218
+ it('retorna success quando sem name/email e hash confere', async () => {
219
+ const { service } = makeService();
220
+ await expect(
221
+ service.verifyCode('en', 1, '123456', 'hash(123456)'),
222
+ ).resolves.toEqual({ success: true });
223
+ });
224
+
225
+ it('cria user_mfa quando name e email presentes', async () => {
226
+ const { service, deps } = makeService();
227
+ deps.prisma.user_mfa.create.mockResolvedValue({ id: 3, user_id: 1 });
228
+ await expect(
229
+ service.verifyCode('en', 1, '123456', 'hash(123456)', 'Nome', 'e@x.com'),
230
+ ).resolves.toMatchObject({ id: 3 });
231
+ expect(deps.prisma.user_mfa.create).toHaveBeenCalled();
232
+ });
233
+ });
234
+
235
+ describe('verifyMfaEmail', () => {
236
+ it('lança NotFound quando usuário não existe', async () => {
237
+ const { service, deps } = makeService();
238
+ deps.setting.getSettingValues.mockResolvedValue({});
239
+ deps.prisma.user.findUnique.mockResolvedValue(null);
240
+ await expect(
241
+ service.verifyMfaEmail('en', 1, 'a@b.com'),
242
+ ).rejects.toThrow(NotFoundException);
243
+ });
244
+
245
+ it('gera código, cria challenge e retorna challengeId', async () => {
246
+ const { service, deps } = makeService();
247
+ deps.setting.getSettingValues.mockResolvedValue({
248
+ 'mfa-email-code-length': 6,
249
+ 'mfa-challenge-expiration-minutes': 15,
250
+ });
251
+ deps.prisma.user.findUnique.mockResolvedValue({ name: 'Bob' });
252
+ deps.prisma.user_mfa.create.mockResolvedValue({
253
+ id: 3,
254
+ user_mfa_challenge: [{ id: 42 }],
255
+ });
256
+
257
+ const result = await service.verifyMfaEmail('en', 1, 'a@b.com');
258
+
259
+ expect(result).toEqual({ challengeId: 42 });
260
+ // código estabilizado: cada byte 7 -> '7' repetido 6x
261
+ expect(deps.mail.sendTemplatedMail).toHaveBeenCalledWith(
262
+ 'en',
263
+ expect.objectContaining({
264
+ slug: 'auth-email-verification-code',
265
+ variables: expect.objectContaining({ code: '777777', name: 'Bob' }),
266
+ }),
267
+ );
268
+ });
269
+ });
270
+
271
+ describe('sendMfaCodeToMultipleEmails', () => {
272
+ it('retorna success sem enviar quando lista vazia', async () => {
273
+ const { service, deps } = makeService();
274
+ await expect(
275
+ service.sendMfaCodeToMultipleEmails('en', 1, []),
276
+ ).resolves.toEqual({ success: true });
277
+ expect(deps.prisma.user.findUnique).not.toHaveBeenCalled();
278
+ });
279
+
280
+ it('lança BadRequest quando usuário não existe', async () => {
281
+ const { service, deps } = makeService();
282
+ deps.prisma.user.findUnique.mockResolvedValue(null);
283
+ await expect(
284
+ service.sendMfaCodeToMultipleEmails('en', 1, ['a@b.com']),
285
+ ).rejects.toThrow(BadRequestException);
286
+ });
287
+
288
+ it('envia para cada email usando mfa existente', async () => {
289
+ const { service, deps } = makeService();
290
+ deps.prisma.user.findUnique.mockResolvedValue({ name: 'Bob' });
291
+ deps.setting.getSettingValues.mockResolvedValue({
292
+ 'mfa-email-code-length': 6,
293
+ 'mfa-challenge-expiration-minutes': 15,
294
+ });
295
+ deps.prisma.user_mfa.findFirst.mockResolvedValue({ id: 5 });
296
+ deps.prisma.user_mfa_challenge.create.mockResolvedValue({ id: 1 });
297
+
298
+ await service.sendMfaCodeToMultipleEmails('en', 1, ['a@b.com', 'c@d.com']);
299
+
300
+ expect(deps.mail.sendTemplatedMail).toHaveBeenCalledTimes(2);
301
+ const createArg = deps.prisma.user_mfa_challenge.create.mock.calls[0][0];
302
+ expect(createArg.data.user_mfa).toEqual({ connect: { id: 5 } });
303
+ });
304
+ });
305
+
306
+ describe('sendMfaCodeByEmail', () => {
307
+ it('lança BadRequest quando usuário não existe', async () => {
308
+ const { service, deps } = makeService();
309
+ deps.prisma.user.findUnique.mockResolvedValue(null);
310
+ await expect(
311
+ service.sendMfaCodeByEmail('en', 1, 'a@b.com'),
312
+ ).rejects.toThrow(BadRequestException);
313
+ });
314
+
315
+ it('cria user_mfa quando não existe (branch create)', async () => {
316
+ const { service, deps } = makeService();
317
+ deps.prisma.user.findUnique.mockResolvedValue({ name: 'Bob' });
318
+ deps.setting.getSettingValues.mockResolvedValue({
319
+ 'mfa-email-code-length': 6,
320
+ 'mfa-challenge-expiration-minutes': 15,
321
+ });
322
+ deps.prisma.user_mfa.findFirst.mockResolvedValue(null);
323
+ deps.prisma.user_mfa_challenge.create.mockResolvedValue({ id: 1 });
324
+
325
+ await service.sendMfaCodeByEmail('en', 1, 'a@b.com');
326
+
327
+ const createArg = deps.prisma.user_mfa_challenge.create.mock.calls[0][0];
328
+ expect(createArg.data.user_mfa.create).toMatchObject({
329
+ name: 'Bob',
330
+ type: 'email',
331
+ });
332
+ });
333
+ });
334
+ });
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import { DashboardCoreService } from './dashboard-core.service';
3
+
4
+ const makeService = (prisma: any = {}, setting: any = {}) =>
5
+ new DashboardCoreService(prisma as any, setting as any);
6
+
7
+ describe('DashboardCoreService helpers puros', () => {
8
+ let service: DashboardCoreService;
9
+
10
+ beforeEach(() => {
11
+ service = makeService();
12
+ });
13
+
14
+ afterEach(() => {
15
+ jest.restoreAllMocks();
16
+ });
17
+
18
+ describe('calculateChange', () => {
19
+ it('retorna 0 quando previous e current são 0', () => {
20
+ expect(service.calculateChange(0, 0)).toBe(0);
21
+ });
22
+
23
+ it('retorna 100 quando previous é 0 e current > 0', () => {
24
+ expect(service.calculateChange(50, 0)).toBe(100);
25
+ });
26
+
27
+ it('calcula variação percentual com uma casa decimal', () => {
28
+ expect(service.calculateChange(150, 100)).toBe(50);
29
+ expect(service.calculateChange(110, 100)).toBe(10);
30
+ expect(service.calculateChange(133, 100)).toBe(33);
31
+ });
32
+
33
+ it('arredonda para uma casa decimal', () => {
34
+ // (1/3)*100 = 33.333... => 33.3
35
+ expect(service.calculateChange(4, 3)).toBe(33.3);
36
+ });
37
+
38
+ it('retorna valores negativos quando há queda', () => {
39
+ expect(service.calculateChange(50, 100)).toBe(-50);
40
+ });
41
+ });
42
+
43
+ describe('userHasRequiredRolesForDashboard', () => {
44
+ const call = (reqs: number[][], roles: number[]) =>
45
+ (service as any).userHasRequiredRolesForDashboard(reqs, roles);
46
+
47
+ it('retorna true quando não há requisitos de componente', () => {
48
+ expect(call([], [1, 2])).toBe(true);
49
+ expect(call([], [])).toBe(true);
50
+ });
51
+
52
+ it('sem roles do usuário, exige que todos os componentes não peçam roles', () => {
53
+ expect(call([[], []], [])).toBe(true);
54
+ expect(call([[], [5]], [])).toBe(false);
55
+ });
56
+
57
+ it('usuário com role satisfaz componente quando há interseção', () => {
58
+ expect(call([[5, 6]], [6])).toBe(true);
59
+ });
60
+
61
+ it('componente sem requisitos é sempre satisfeito', () => {
62
+ expect(call([[], [3]], [3])).toBe(true);
63
+ });
64
+
65
+ it('falha quando algum componente não tem interseção de roles', () => {
66
+ expect(call([[3], [99]], [3])).toBe(false);
67
+ });
68
+ });
69
+
70
+ describe('hasConfigValue', () => {
71
+ const call = (v: unknown) => (service as any).hasConfigValue(v);
72
+
73
+ it('array vazio é falso; array com item é verdadeiro', () => {
74
+ expect(call([])).toBe(false);
75
+ expect(call([1])).toBe(true);
76
+ });
77
+
78
+ it('string vazia/branca é falso; string com conteúdo é verdadeiro', () => {
79
+ expect(call('')).toBe(false);
80
+ expect(call(' ')).toBe(false);
81
+ expect(call('x')).toBe(true);
82
+ });
83
+
84
+ it('null/undefined são falsos; números/booleans são verdadeiros', () => {
85
+ expect(call(null)).toBe(false);
86
+ expect(call(undefined)).toBe(false);
87
+ expect(call(0)).toBe(true);
88
+ expect(call(false)).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe('toNullableString', () => {
93
+ const call = (v: unknown) => (service as any).toNullableString(v);
94
+
95
+ it('retorna string trimada quando não vazia', () => {
96
+ expect(call(' hi ')).toBe('hi');
97
+ });
98
+
99
+ it('retorna null para string vazia/branca', () => {
100
+ expect(call(' ')).toBeNull();
101
+ });
102
+
103
+ it('retorna null para null/undefined', () => {
104
+ expect(call(null)).toBeNull();
105
+ expect(call(undefined)).toBeNull();
106
+ });
107
+
108
+ it('converte não-strings via String()', () => {
109
+ expect(call(42)).toBe('42');
110
+ expect(call(true)).toBe('true');
111
+ });
112
+ });
113
+
114
+ describe('toNullableUppercaseString', () => {
115
+ const call = (v: unknown) => (service as any).toNullableUppercaseString(v);
116
+
117
+ it('faz uppercase de string válida', () => {
118
+ expect(call(' smtp ')).toBe('SMTP');
119
+ });
120
+
121
+ it('retorna null para vazio', () => {
122
+ expect(call(' ')).toBeNull();
123
+ expect(call(null)).toBeNull();
124
+ });
125
+ });
126
+
127
+ describe('normalizeProviderList', () => {
128
+ const call = (v: unknown) => (service as any).normalizeProviderList(v);
129
+
130
+ it('retorna [] quando não é array', () => {
131
+ expect(call(undefined)).toEqual([]);
132
+ expect(call('x')).toEqual([]);
133
+ });
134
+
135
+ it('faz lowercase de cada provider', () => {
136
+ expect(call(['Google', 'GITHUB'])).toEqual(['google', 'github']);
137
+ });
138
+ });
139
+
140
+ describe('getMissingSettingKeys', () => {
141
+ const call = (settings: Record<string, unknown>, keys: string[]) =>
142
+ (service as any).getMissingSettingKeys(settings, keys);
143
+
144
+ it('retorna apenas as chaves ausentes/vazias', () => {
145
+ const settings = { a: 'x', b: '', c: null, d: 'y' };
146
+ expect(call(settings, ['a', 'b', 'c', 'd'])).toEqual(['b', 'c']);
147
+ });
148
+
149
+ it('retorna [] quando todas presentes', () => {
150
+ expect(call({ a: '1', b: '2' }, ['a', 'b'])).toEqual([]);
151
+ });
152
+ });
153
+
154
+ describe('slugifyDashboardName', () => {
155
+ const call = (v: string) => (service as any).slugifyDashboardName(v);
156
+
157
+ it('remove diacríticos e normaliza para slug', () => {
158
+ expect(call('Relatório de Vendas')).toBe('relatorio-de-vendas');
159
+ });
160
+
161
+ it('remove hifens das bordas', () => {
162
+ expect(call(' --Hello World!! ')).toBe('hello-world');
163
+ });
164
+
165
+ it('gera fallback quando resultado vazio', () => {
166
+ const result = call('!!!');
167
+ expect(result).toMatch(/^dashboard-\d+$/);
168
+ });
169
+ });
170
+
171
+ describe('buildUniqueDashboardSlug', () => {
172
+ it('retorna o baseSlug quando não há colisão', async () => {
173
+ const prisma = { dashboard: { findFirst: jest.fn().mockResolvedValue(null) } };
174
+ const svc = makeService(prisma);
175
+ await expect(
176
+ (svc as any).buildUniqueDashboardSlug('vendas'),
177
+ ).resolves.toBe('vendas');
178
+ });
179
+
180
+ it('adiciona sufixo incremental em colisões', async () => {
181
+ const findFirst = jest
182
+ .fn()
183
+ .mockResolvedValueOnce({ id: 1 }) // 'vendas' existe
184
+ .mockResolvedValueOnce({ id: 2 }) // 'vendas-2' existe
185
+ .mockResolvedValueOnce(null); // 'vendas-3' livre
186
+ const svc = makeService({ dashboard: { findFirst } });
187
+ await expect(
188
+ (svc as any).buildUniqueDashboardSlug('vendas'),
189
+ ).resolves.toBe('vendas-3');
190
+ expect(findFirst).toHaveBeenCalledTimes(3);
191
+ });
192
+ });
193
+ });