@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.
- package/README.md +83 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +4 -0
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/integration-profile.utils.d.ts +6 -0
- package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.utils.js +1 -1
- package/dist/integration-profile/integration-profile.utils.js.map +1 -1
- package/dist/oauth/oauth.controller.d.ts +11 -3
- package/dist/oauth/oauth.controller.d.ts.map +1 -1
- package/dist/oauth/oauth.controller.js +52 -14
- package/dist/oauth/oauth.controller.js.map +1 -1
- package/dist/oauth/oauth.module.d.ts.map +1 -1
- package/dist/oauth/oauth.module.js +4 -0
- package/dist/oauth/oauth.module.js.map +1 -1
- package/dist/oauth/oauth.service.d.ts +38 -2
- package/dist/oauth/oauth.service.d.ts.map +1 -1
- package/dist/oauth/oauth.service.js +122 -5
- package/dist/oauth/oauth.service.js.map +1 -1
- package/dist/oauth/providers/apple.provider.d.ts +34 -0
- package/dist/oauth/providers/apple.provider.d.ts.map +1 -0
- package/dist/oauth/providers/apple.provider.js +197 -0
- package/dist/oauth/providers/apple.provider.js.map +1 -0
- package/dist/oauth/providers/facebook.provider.d.ts +1 -1
- package/dist/oauth/providers/facebook.provider.d.ts.map +1 -1
- package/dist/oauth/providers/facebook.provider.js +3 -2
- package/dist/oauth/providers/facebook.provider.js.map +1 -1
- package/dist/oauth/providers/google.provider.d.ts +1 -1
- package/dist/oauth/providers/google.provider.d.ts.map +1 -1
- package/dist/oauth/providers/google.provider.js +3 -2
- package/dist/oauth/providers/google.provider.js.map +1 -1
- package/dist/oauth/providers/linkedin.provider.d.ts +25 -0
- package/dist/oauth/providers/linkedin.provider.d.ts.map +1 -0
- package/dist/oauth/providers/linkedin.provider.js +99 -0
- package/dist/oauth/providers/linkedin.provider.js.map +1 -0
- package/dist/oauth/providers/microsoft-entra-id.provider.js +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/oauth/providers/microsoft.provider.d.ts +1 -1
- package/dist/oauth/providers/microsoft.provider.d.ts.map +1 -1
- package/dist/oauth/providers/microsoft.provider.js +3 -2
- package/dist/oauth/providers/microsoft.provider.js.map +1 -1
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +19 -2
- package/dist/pdf/pdf.service.js.map +1 -1
- package/dist/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +4 -1
- package/dist/setting/setting.service.js.map +1 -1
- package/hedhog/data/integration_provider.yaml +16 -0
- package/hedhog/data/setting_group.yaml +98 -0
- package/hedhog/data/setting_subgroup.yaml +16 -0
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +2 -0
- package/hedhog/frontend/app/dashboard/dashboard.css.ejs +11 -11
- package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +57 -9
- package/hedhog/frontend/app/menu/page.tsx.ejs +1 -1
- package/hedhog/frontend/app/users/user-sheet.tsx.ejs +8 -0
- package/hedhog/frontend/messages/en.json +35 -0
- package/hedhog/frontend/messages/pt.json +35 -0
- package/hedhog/table/user_account.yaml +1 -0
- package/package.json +4 -4
- package/src/access-log/access-log.service.spec.ts +235 -0
- package/src/ai-instruction/ai-prompt-builder.service.spec.ts +250 -0
- package/src/auth/auth.service.spec.ts +891 -0
- package/src/auth/guards/auth.guard.spec.ts +177 -0
- package/src/challenge/challenge.service.spec.ts +334 -0
- package/src/dashboard/dashboard-core/dashboard-core.service.spec.ts +193 -0
- package/src/file/image-optimization.service.spec.ts +283 -0
- package/src/file/provider/provider.factory.spec.ts +111 -0
- package/src/install/install.service.spec.ts +139 -0
- package/src/integration/services/event-subscriber.registry.spec.ts +143 -0
- package/src/integration/services/inbox.service.spec.ts +183 -0
- package/src/integration/services/integration-link.service.spec.ts +217 -0
- package/src/integration/services/outbox.service.spec.ts +290 -0
- package/src/integration-profile/integration-credential-crypto.service.spec.ts +289 -0
- package/src/integration-profile/integration-profile.secrets.ts +4 -0
- package/src/integration-profile/integration-profile.utils.spec.ts +332 -0
- package/src/integration-profile/integration-profile.utils.ts +14 -1
- package/src/locale/locale.service.spec.ts +78 -0
- package/src/mail/mail.service.spec.ts +257 -0
- package/src/mcp/guards/mcp-auth.guard.spec.ts +87 -0
- package/src/mcp/guards/mcp-permission.guard.spec.ts +64 -0
- package/src/mcp/mcp-api-key.service.spec.ts +125 -0
- package/src/mcp/mcp-registry.service.spec.ts +96 -0
- package/src/mcp/mcp-server.service.spec.ts +187 -0
- package/src/menu/menu.service.spec.ts +76 -0
- package/src/notification/notification.service.spec.ts +297 -0
- package/src/oauth/oauth.controller.ts +53 -10
- package/src/oauth/oauth.module.ts +4 -0
- package/src/oauth/oauth.service.spec.ts +332 -0
- package/src/oauth/oauth.service.ts +156 -1
- package/src/oauth/providers/apple.provider.ts +175 -0
- package/src/oauth/providers/facebook.provider.ts +3 -2
- package/src/oauth/providers/google.provider.ts +3 -2
- package/src/oauth/providers/linkedin.provider.ts +96 -0
- package/src/oauth/providers/microsoft-entra-id.provider.ts +1 -1
- package/src/oauth/providers/microsoft.provider.ts +3 -2
- package/src/pdf/pdf.service.ts +20 -2
- package/src/role/guards/role.guard.spec.ts +157 -0
- package/src/role/role.service.spec.ts +179 -0
- package/src/session/session.service.spec.ts +376 -0
- package/src/setting/setting.service.spec.ts +316 -0
- package/src/setting/setting.service.ts +4 -1
- package/src/task/task.service.spec.ts +47 -0
- package/src/token/token.service.spec.ts +351 -0
- package/src/user/user.service.spec.ts +256 -0
- package/src/utils/locale-context.spec.ts +22 -0
- package/src/utils/webauthn-origin.spec.ts +73 -0
- package/src/webhook-integration/integration-event-catalog.service.spec.ts +149 -0
|
@@ -484,13 +484,16 @@ export class SettingService implements OnModuleInit, OnModuleDestroy {
|
|
|
484
484
|
'oauth-google-enabled',
|
|
485
485
|
'oauth-microsoft-enabled',
|
|
486
486
|
'oauth-microsoft-entra-id-enabled',
|
|
487
|
+
'oauth-apple-enabled',
|
|
488
|
+
'oauth-linkedin-enabled',
|
|
487
489
|
'disable-authentication-with-email-and-password',
|
|
488
490
|
'contact-allow-company-registration',
|
|
489
491
|
'operations.comment-edit-window',
|
|
490
492
|
'lms-video-conversion-enabled',
|
|
491
493
|
'lms-video-frame-capture-interval-seconds',
|
|
492
494
|
'lms-error-modal-enabled',
|
|
493
|
-
'url'
|
|
495
|
+
'url',
|
|
496
|
+
'app-urls'
|
|
494
497
|
];
|
|
495
498
|
const setting = await this.getSettingValues(slugs);
|
|
496
499
|
return { locales, setting };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
|
2
|
+
import { TasksService } from './task.service';
|
|
3
|
+
|
|
4
|
+
const makeDeps = () => {
|
|
5
|
+
const prismaService = { $executeRaw: jest.fn().mockResolvedValue(0) };
|
|
6
|
+
const settingService = { getSettingValues: jest.fn().mockResolvedValue({}) };
|
|
7
|
+
return { prismaService, settingService };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const makeService = (deps = makeDeps()) => ({
|
|
11
|
+
service: new TasksService(deps.prismaService as any, deps.settingService as any),
|
|
12
|
+
deps,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('TasksService', () => {
|
|
16
|
+
afterEach(() => jest.clearAllMocks());
|
|
17
|
+
|
|
18
|
+
describe('clear', () => {
|
|
19
|
+
it('executa todas as rotinas de limpeza', async () => {
|
|
20
|
+
const { service, deps } = makeService();
|
|
21
|
+
await service.clear();
|
|
22
|
+
// 8 rotinas, cada uma faz um $executeRaw
|
|
23
|
+
expect(deps.prismaService.$executeRaw).toHaveBeenCalledTimes(8);
|
|
24
|
+
expect(deps.settingService.getSettingValues).toHaveBeenCalled();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('clearExpiredMfaChallenges (default de batch limit)', () => {
|
|
29
|
+
it('usa 20000 quando setting ausente', async () => {
|
|
30
|
+
const { service, deps } = makeService();
|
|
31
|
+
deps.settingService.getSettingValues.mockResolvedValue({});
|
|
32
|
+
await (service as any).clearExpiredMfaChallenges();
|
|
33
|
+
const values = deps.prismaService.$executeRaw.mock.calls[0].slice(1);
|
|
34
|
+
expect(values).toContain(20000);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('respeita cleanup-batch-limit configurado', async () => {
|
|
38
|
+
const { service, deps } = makeService();
|
|
39
|
+
deps.settingService.getSettingValues.mockResolvedValue({
|
|
40
|
+
'cleanup-batch-limit': 500,
|
|
41
|
+
});
|
|
42
|
+
await (service as any).clearExpiredMfaChallenges();
|
|
43
|
+
const values = deps.prismaService.$executeRaw.mock.calls[0].slice(1);
|
|
44
|
+
expect(values).toContain(500);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
it,
|
|
4
|
+
expect,
|
|
5
|
+
beforeEach,
|
|
6
|
+
afterEach,
|
|
7
|
+
jest,
|
|
8
|
+
} from '@jest/globals';
|
|
9
|
+
import {
|
|
10
|
+
ForbiddenException,
|
|
11
|
+
UnauthorizedException,
|
|
12
|
+
} from '@nestjs/common';
|
|
13
|
+
import { TokenService } from './token.service';
|
|
14
|
+
|
|
15
|
+
const makeDeps = () => {
|
|
16
|
+
const jwt = {
|
|
17
|
+
signAsync: jest.fn(),
|
|
18
|
+
verifyAsync: jest.fn(),
|
|
19
|
+
};
|
|
20
|
+
const security = {
|
|
21
|
+
getJwtSecret: jest.fn().mockReturnValue('secret'),
|
|
22
|
+
randomOpaque: jest.fn().mockReturnValue('opaque-token'),
|
|
23
|
+
};
|
|
24
|
+
const setting = {
|
|
25
|
+
getSettingValues: jest.fn().mockResolvedValue({}),
|
|
26
|
+
};
|
|
27
|
+
const prisma = {
|
|
28
|
+
user_session: { findUnique: jest.fn() },
|
|
29
|
+
};
|
|
30
|
+
return { jwt, security, setting, prisma };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const makeService = (deps = makeDeps()) => ({
|
|
34
|
+
service: new TokenService(
|
|
35
|
+
deps.prisma as any,
|
|
36
|
+
deps.jwt as any,
|
|
37
|
+
deps.security as any,
|
|
38
|
+
deps.setting as any,
|
|
39
|
+
),
|
|
40
|
+
deps,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('TokenService', () => {
|
|
44
|
+
const originalCookieDomain = process.env.COOKIE_DOMAIN;
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
jest.clearAllMocks();
|
|
48
|
+
if (originalCookieDomain === undefined) {
|
|
49
|
+
delete process.env.COOKIE_DOMAIN;
|
|
50
|
+
} else {
|
|
51
|
+
process.env.COOKIE_DOMAIN = originalCookieDomain;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('normalizeCookieDomain', () => {
|
|
56
|
+
let service: TokenService;
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
service = makeService().service;
|
|
59
|
+
});
|
|
60
|
+
const call = (v?: string | null) =>
|
|
61
|
+
(service as any).normalizeCookieDomain(v);
|
|
62
|
+
|
|
63
|
+
it('retorna undefined para vazio/null/branco', () => {
|
|
64
|
+
expect(call(undefined)).toBeUndefined();
|
|
65
|
+
expect(call(null)).toBeUndefined();
|
|
66
|
+
expect(call(' ')).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('remove protocolo, path e porta', () => {
|
|
70
|
+
expect(call('https://app.example.com:3200/login')).toBe(
|
|
71
|
+
'app.example.com',
|
|
72
|
+
);
|
|
73
|
+
expect(call('http://app.example.com')).toBe('app.example.com');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('retorna undefined para localhost', () => {
|
|
77
|
+
expect(call('http://localhost:3200')).toBeUndefined();
|
|
78
|
+
expect(call('localhost')).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('retorna undefined para IPv4', () => {
|
|
82
|
+
expect(call('http://127.0.0.1:3000')).toBeUndefined();
|
|
83
|
+
expect(call('192.168.0.10')).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('retorna host válido para domínio real', () => {
|
|
87
|
+
expect(call('example.com')).toBe('example.com');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('getCookieDomain', () => {
|
|
92
|
+
it('prioriza COOKIE_DOMAIN do env', async () => {
|
|
93
|
+
process.env.COOKIE_DOMAIN = 'https://env.example.com/';
|
|
94
|
+
const { service, deps } = makeService();
|
|
95
|
+
await expect((service as any).getCookieDomain()).resolves.toBe(
|
|
96
|
+
'env.example.com',
|
|
97
|
+
);
|
|
98
|
+
expect(deps.setting.getSettingValues).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('cai para setting api-url quando env ausente/inválido', async () => {
|
|
102
|
+
delete process.env.COOKIE_DOMAIN;
|
|
103
|
+
const { service, deps } = makeService();
|
|
104
|
+
deps.setting.getSettingValues.mockResolvedValue({
|
|
105
|
+
'api-url': 'https://api.example.com',
|
|
106
|
+
});
|
|
107
|
+
await expect((service as any).getCookieDomain()).resolves.toBe(
|
|
108
|
+
'api.example.com',
|
|
109
|
+
);
|
|
110
|
+
expect(deps.setting.getSettingValues).toHaveBeenCalledWith(['api-url']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('retorna undefined quando env é localhost e setting vazio', async () => {
|
|
114
|
+
process.env.COOKIE_DOMAIN = 'localhost';
|
|
115
|
+
const { service, deps } = makeService();
|
|
116
|
+
deps.setting.getSettingValues.mockResolvedValue({ 'api-url': '' });
|
|
117
|
+
await expect((service as any).getCookieDomain()).resolves.toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('setRefreshTokenCookie', () => {
|
|
122
|
+
it('usa sameSite lax sem secure/domain em localhost', async () => {
|
|
123
|
+
delete process.env.COOKIE_DOMAIN;
|
|
124
|
+
const { service, deps } = makeService();
|
|
125
|
+
deps.setting.getSettingValues.mockResolvedValue({});
|
|
126
|
+
const res = { cookie: jest.fn() };
|
|
127
|
+
const expires = new Date(Date.now() + 60000);
|
|
128
|
+
|
|
129
|
+
await service.setRefreshTokenCookie('en', res, 'tok', expires);
|
|
130
|
+
|
|
131
|
+
const [name, token, opts] = res.cookie.mock.calls[0];
|
|
132
|
+
expect(name).toBe('rt');
|
|
133
|
+
expect(token).toBe('tok');
|
|
134
|
+
expect(opts.sameSite).toBe('lax');
|
|
135
|
+
expect(opts.secure).toBeUndefined();
|
|
136
|
+
expect(opts.domain).toBeUndefined();
|
|
137
|
+
expect(opts.httpOnly).toBe(true);
|
|
138
|
+
expect(opts.maxAge).toBeGreaterThan(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('usa sameSite none + secure + domain quando há domínio', async () => {
|
|
142
|
+
process.env.COOKIE_DOMAIN = 'app.example.com';
|
|
143
|
+
const { service } = makeService();
|
|
144
|
+
const res = { cookie: jest.fn() };
|
|
145
|
+
const expires = new Date(Date.now() + 60000);
|
|
146
|
+
|
|
147
|
+
await service.setRefreshTokenCookie('en', res, 'tok', expires);
|
|
148
|
+
|
|
149
|
+
const opts = res.cookie.mock.calls[0][2];
|
|
150
|
+
expect(opts.sameSite).toBe('none');
|
|
151
|
+
expect(opts.secure).toBe(true);
|
|
152
|
+
expect(opts.domain).toBe('app.example.com');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('removeRefreshTokenCookie', () => {
|
|
157
|
+
it('limpa cookie com opções de localhost', async () => {
|
|
158
|
+
delete process.env.COOKIE_DOMAIN;
|
|
159
|
+
const { service, deps } = makeService();
|
|
160
|
+
deps.setting.getSettingValues.mockResolvedValue({});
|
|
161
|
+
const res = { clearCookie: jest.fn() };
|
|
162
|
+
|
|
163
|
+
await service.removeRefreshTokenCookie(res);
|
|
164
|
+
|
|
165
|
+
const [name, opts] = res.clearCookie.mock.calls[0];
|
|
166
|
+
expect(name).toBe('rt');
|
|
167
|
+
expect(opts.sameSite).toBe('lax');
|
|
168
|
+
expect(opts.secure).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('limpa cookie com domain em produção', async () => {
|
|
172
|
+
process.env.COOKIE_DOMAIN = 'app.example.com';
|
|
173
|
+
const { service } = makeService();
|
|
174
|
+
const res = { clearCookie: jest.fn() };
|
|
175
|
+
|
|
176
|
+
await service.removeRefreshTokenCookie(res);
|
|
177
|
+
|
|
178
|
+
const opts = res.clearCookie.mock.calls[0][1];
|
|
179
|
+
expect(opts.secure).toBe(true);
|
|
180
|
+
expect(opts.domain).toBe('app.example.com');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('verify', () => {
|
|
185
|
+
it('retorna payload sem sessionId', async () => {
|
|
186
|
+
const { service, deps } = makeService();
|
|
187
|
+
deps.jwt.verifyAsync.mockResolvedValue({ sub: 1 });
|
|
188
|
+
await expect(service.verify('en', 'tok')).resolves.toEqual({ sub: 1 });
|
|
189
|
+
expect(deps.prisma.user_session.findUnique).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('retorna payload quando sessão ativa', async () => {
|
|
193
|
+
const { service, deps } = makeService();
|
|
194
|
+
deps.jwt.verifyAsync.mockResolvedValue({ sub: 1, sessionId: 9 });
|
|
195
|
+
deps.prisma.user_session.findUnique.mockResolvedValue({
|
|
196
|
+
revoked_at: null,
|
|
197
|
+
expires_at: new Date(Date.now() + 100000),
|
|
198
|
+
});
|
|
199
|
+
await expect(service.verify('en', 'tok')).resolves.toMatchObject({
|
|
200
|
+
sessionId: 9,
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('lança UnauthorizedException quando sessão não existe', async () => {
|
|
205
|
+
const { service, deps } = makeService();
|
|
206
|
+
deps.jwt.verifyAsync.mockResolvedValue({ sub: 1, sessionId: 9 });
|
|
207
|
+
deps.prisma.user_session.findUnique.mockResolvedValue(null);
|
|
208
|
+
await expect(service.verify('en', 'tok')).rejects.toThrow(
|
|
209
|
+
UnauthorizedException,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('lança UnauthorizedException quando sessão revogada', async () => {
|
|
214
|
+
const { service, deps } = makeService();
|
|
215
|
+
deps.jwt.verifyAsync.mockResolvedValue({ sub: 1, sessionId: 9 });
|
|
216
|
+
deps.prisma.user_session.findUnique.mockResolvedValue({
|
|
217
|
+
revoked_at: new Date(),
|
|
218
|
+
expires_at: new Date(Date.now() + 100000),
|
|
219
|
+
});
|
|
220
|
+
await expect(service.verify('en', 'tok')).rejects.toThrow(
|
|
221
|
+
UnauthorizedException,
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('lança UnauthorizedException quando sessão expirada', async () => {
|
|
226
|
+
const { service, deps } = makeService();
|
|
227
|
+
deps.jwt.verifyAsync.mockResolvedValue({ sub: 1, sessionId: 9 });
|
|
228
|
+
deps.prisma.user_session.findUnique.mockResolvedValue({
|
|
229
|
+
revoked_at: null,
|
|
230
|
+
expires_at: new Date(Date.now() - 1000),
|
|
231
|
+
});
|
|
232
|
+
await expect(service.verify('en', 'tok')).rejects.toThrow(
|
|
233
|
+
UnauthorizedException,
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('engole erro transitório de DB e retorna payload', async () => {
|
|
238
|
+
const { service, deps } = makeService();
|
|
239
|
+
deps.jwt.verifyAsync.mockResolvedValue({ sub: 1, sessionId: 9 });
|
|
240
|
+
deps.prisma.user_session.findUnique.mockRejectedValue(
|
|
241
|
+
new Error('connection reset'),
|
|
242
|
+
);
|
|
243
|
+
await expect(service.verify('en', 'tok')).resolves.toMatchObject({
|
|
244
|
+
sub: 1,
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('converte erro de JWT em UnauthorizedException', async () => {
|
|
249
|
+
const { service, deps } = makeService();
|
|
250
|
+
const jwtError: any = new Error('jwt malformed');
|
|
251
|
+
jwtError.name = 'JsonWebTokenError';
|
|
252
|
+
deps.jwt.verifyAsync.mockRejectedValue(jwtError);
|
|
253
|
+
await expect(service.verify('en', 'tok')).rejects.toThrow(
|
|
254
|
+
UnauthorizedException,
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('trata TokenExpiredError como 401', async () => {
|
|
259
|
+
const { service, deps } = makeService();
|
|
260
|
+
const jwtError: any = new Error('jwt expired');
|
|
261
|
+
jwtError.name = 'TokenExpiredError';
|
|
262
|
+
deps.jwt.verifyAsync.mockRejectedValue(jwtError);
|
|
263
|
+
await expect(service.verify('en', 'tok')).rejects.toThrow(
|
|
264
|
+
UnauthorizedException,
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('token helpers', () => {
|
|
270
|
+
it('createAccessToken assina com o segredo', async () => {
|
|
271
|
+
const { service, deps } = makeService();
|
|
272
|
+
deps.jwt.signAsync.mockResolvedValue('signed');
|
|
273
|
+
await expect(service.createAccessToken({ sub: 1 })).resolves.toBe(
|
|
274
|
+
'signed',
|
|
275
|
+
);
|
|
276
|
+
expect(deps.jwt.signAsync).toHaveBeenCalledWith(
|
|
277
|
+
{ sub: 1 },
|
|
278
|
+
{ secret: 'secret' },
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('createOpaqueToken delega ao security', async () => {
|
|
283
|
+
const { service, deps } = makeService();
|
|
284
|
+
await expect(service.createOpaqueToken(32)).resolves.toBe('opaque-token');
|
|
285
|
+
expect(deps.security.randomOpaque).toHaveBeenCalledWith(32);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('createMfaChallengeToken assina com expiração de 10m', async () => {
|
|
289
|
+
const { service, deps } = makeService();
|
|
290
|
+
deps.jwt.signAsync.mockResolvedValue('mfa-tok');
|
|
291
|
+
const payload = {
|
|
292
|
+
userId: 1,
|
|
293
|
+
ipAddress: '1.2.3.4',
|
|
294
|
+
userAgent: 'ua',
|
|
295
|
+
email: 'a@b.com',
|
|
296
|
+
};
|
|
297
|
+
await expect(service.createMfaChallengeToken(payload)).resolves.toBe(
|
|
298
|
+
'mfa-tok',
|
|
299
|
+
);
|
|
300
|
+
expect(deps.jwt.signAsync).toHaveBeenCalledWith(
|
|
301
|
+
payload,
|
|
302
|
+
expect.objectContaining({ secret: 'secret', expiresIn: '10m' }),
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('verifyMfaChallengeToken retorna campos do payload', async () => {
|
|
307
|
+
const { service, deps } = makeService();
|
|
308
|
+
deps.jwt.verifyAsync.mockResolvedValue({
|
|
309
|
+
userId: 5,
|
|
310
|
+
ipAddress: '9.9.9.9',
|
|
311
|
+
userAgent: 'ua',
|
|
312
|
+
email: 'x@y.com',
|
|
313
|
+
extra: 'ignored',
|
|
314
|
+
});
|
|
315
|
+
await expect(service.verifyMfaChallengeToken('tok')).resolves.toEqual({
|
|
316
|
+
userId: 5,
|
|
317
|
+
ipAddress: '9.9.9.9',
|
|
318
|
+
userAgent: 'ua',
|
|
319
|
+
email: 'x@y.com',
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('verifyMfaChallengeToken lança ForbiddenException em token inválido', async () => {
|
|
324
|
+
const { service, deps } = makeService();
|
|
325
|
+
deps.jwt.verifyAsync.mockRejectedValue(new Error('bad'));
|
|
326
|
+
await expect(service.verifyMfaChallengeToken('tok')).rejects.toThrow(
|
|
327
|
+
ForbiddenException,
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('decodeExpiredToken ignora expiração', async () => {
|
|
332
|
+
const { service, deps } = makeService();
|
|
333
|
+
deps.jwt.verifyAsync.mockResolvedValue({ sub: 7 });
|
|
334
|
+
await expect(service.decodeExpiredToken('tok')).resolves.toEqual({
|
|
335
|
+
sub: 7,
|
|
336
|
+
});
|
|
337
|
+
expect(deps.jwt.verifyAsync).toHaveBeenCalledWith(
|
|
338
|
+
'tok',
|
|
339
|
+
expect.objectContaining({ ignoreExpiration: true }),
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('decodeExpiredToken lança UnauthorizedException em token inválido', async () => {
|
|
344
|
+
const { service, deps } = makeService();
|
|
345
|
+
deps.jwt.verifyAsync.mockRejectedValue(new Error('bad'));
|
|
346
|
+
await expect(service.decodeExpiredToken('tok')).rejects.toThrow(
|
|
347
|
+
UnauthorizedException,
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
|
2
|
+
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
3
|
+
import { UserService } from './user.service';
|
|
4
|
+
|
|
5
|
+
const makeDeps = () => {
|
|
6
|
+
const prismaService = {
|
|
7
|
+
user: {
|
|
8
|
+
findUnique: jest.fn(),
|
|
9
|
+
findFirst: jest.fn(),
|
|
10
|
+
findMany: jest.fn(),
|
|
11
|
+
update: jest.fn(),
|
|
12
|
+
deleteMany: jest.fn(),
|
|
13
|
+
},
|
|
14
|
+
role: { findUnique: jest.fn(), findMany: jest.fn() },
|
|
15
|
+
role_user: { findFirst: jest.fn(), create: jest.fn(), delete: jest.fn(), findMany: jest.fn() },
|
|
16
|
+
user_credential: { updateMany: jest.fn(), create: jest.fn() },
|
|
17
|
+
user_activity: { create: jest.fn() },
|
|
18
|
+
user_change_log: { create: jest.fn() },
|
|
19
|
+
user_identifier: { findFirst: jest.fn() },
|
|
20
|
+
file: { delete: jest.fn() },
|
|
21
|
+
$transaction: jest.fn(),
|
|
22
|
+
};
|
|
23
|
+
const paginationService = { paginatePrismaModel: jest.fn() };
|
|
24
|
+
const security = { hashArgon2: jest.fn().mockResolvedValue('hashed') };
|
|
25
|
+
const challenge = { verifyEmail: jest.fn() };
|
|
26
|
+
const file = { upload: jest.fn(), delete: jest.fn(), getBuffer: jest.fn() };
|
|
27
|
+
const integrationApi = { publishEvent: jest.fn().mockResolvedValue(undefined) };
|
|
28
|
+
const settingService = { getSettingValues: jest.fn().mockResolvedValue({}) };
|
|
29
|
+
return {
|
|
30
|
+
prismaService,
|
|
31
|
+
paginationService,
|
|
32
|
+
security,
|
|
33
|
+
challenge,
|
|
34
|
+
file,
|
|
35
|
+
integrationApi,
|
|
36
|
+
settingService,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const makeService = (deps = makeDeps()) => ({
|
|
41
|
+
service: new UserService(
|
|
42
|
+
deps.prismaService as any,
|
|
43
|
+
deps.paginationService as any,
|
|
44
|
+
deps.security as any,
|
|
45
|
+
deps.challenge as any,
|
|
46
|
+
deps.file as any,
|
|
47
|
+
deps.integrationApi as any,
|
|
48
|
+
deps.settingService as any,
|
|
49
|
+
),
|
|
50
|
+
deps,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('UserService', () => {
|
|
54
|
+
afterEach(() => jest.clearAllMocks());
|
|
55
|
+
|
|
56
|
+
describe('registerUserActivity', () => {
|
|
57
|
+
it('ignora ids inválidos', async () => {
|
|
58
|
+
const { service, deps } = makeService();
|
|
59
|
+
await expect(service.registerUserActivity(null, 'x')).resolves.toBeNull();
|
|
60
|
+
await expect(service.registerUserActivity(0, 'x')).resolves.toBeNull();
|
|
61
|
+
await expect(service.registerUserActivity(-1, 'x')).resolves.toBeNull();
|
|
62
|
+
await expect(service.registerUserActivity(1.5, 'x')).resolves.toBeNull();
|
|
63
|
+
expect(deps.prismaService.user_activity.create).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('registra atividade para id válido', async () => {
|
|
67
|
+
const { service, deps } = makeService();
|
|
68
|
+
deps.prismaService.user_activity.create.mockResolvedValue({ id: 1 });
|
|
69
|
+
await service.registerUserActivity(5, 'login');
|
|
70
|
+
expect(deps.prismaService.user_activity.create).toHaveBeenCalledWith({
|
|
71
|
+
data: { user_id: 5, action: 'login' },
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('generateRandomPassword', () => {
|
|
77
|
+
const allChars =
|
|
78
|
+
'abcdefghijkmnopqrstuvwxyz' +
|
|
79
|
+
'ABCDEFGHJKLMNPQRSTUVWXYZ' +
|
|
80
|
+
'23456789' +
|
|
81
|
+
'@#$%&*!?-_+';
|
|
82
|
+
|
|
83
|
+
it('gera senha do tamanho padrão (16)', () => {
|
|
84
|
+
const { service } = makeService();
|
|
85
|
+
const pwd = (service as any).generateRandomPassword();
|
|
86
|
+
expect(pwd).toHaveLength(16);
|
|
87
|
+
for (const ch of pwd) expect(allChars).toContain(ch);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('respeita tamanho customizado', () => {
|
|
91
|
+
const { service } = makeService();
|
|
92
|
+
expect((service as any).generateRandomPassword(24)).toHaveLength(24);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('mapRolesWithAssignment', () => {
|
|
97
|
+
it('marca atribuídas e resolve locale com fallback', () => {
|
|
98
|
+
const { service } = makeService();
|
|
99
|
+
const allRoles = [
|
|
100
|
+
{
|
|
101
|
+
id: 1,
|
|
102
|
+
slug: 'admin',
|
|
103
|
+
role_locale: [
|
|
104
|
+
{ locale: { code: 'en' }, name: 'Admin', description: 'A' },
|
|
105
|
+
{ locale: { code: 'pt' }, name: 'Adm', description: 'Ap' },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 2,
|
|
110
|
+
slug: 'user',
|
|
111
|
+
role_locale: [{ locale: { code: 'fr' }, name: 'Utilisateur', description: 'U' }],
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
const userRoles = [{ role_id: 1 }];
|
|
115
|
+
const result = (service as any).mapRolesWithAssignment('pt', allRoles, userRoles);
|
|
116
|
+
expect(result[0]).toMatchObject({ id: 1, name: 'Adm', isAssigned: true });
|
|
117
|
+
// role 2 não tem locale 'pt' -> fallback para o primeiro
|
|
118
|
+
expect(result[1]).toMatchObject({ id: 2, name: 'Utilisateur', isAssigned: false });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('findUserById', () => {
|
|
123
|
+
it('lança NotFound quando ausente', async () => {
|
|
124
|
+
const { service, deps } = makeService();
|
|
125
|
+
deps.prismaService.user.findUnique.mockResolvedValue(null);
|
|
126
|
+
await expect(service.findUserById('en', 9)).rejects.toThrow(
|
|
127
|
+
NotFoundException,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('findUserByEmail', () => {
|
|
133
|
+
it('normaliza email (minúsculas + trim)', async () => {
|
|
134
|
+
const { service, deps } = makeService();
|
|
135
|
+
deps.prismaService.user.findFirst.mockResolvedValue(null);
|
|
136
|
+
await service.findUserByEmail('en', ' User@Example.COM ');
|
|
137
|
+
const where = deps.prismaService.user.findFirst.mock.calls[0][0].where;
|
|
138
|
+
expect(where.user_identifier.some.value).toBe('user@example.com');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('delete', () => {
|
|
143
|
+
it('lança BadRequest sem ids', async () => {
|
|
144
|
+
const { service } = makeService();
|
|
145
|
+
await expect(service.delete('en', { ids: [] } as any)).rejects.toThrow(
|
|
146
|
+
BadRequestException,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('lança NotFound quando algum id não existe', async () => {
|
|
151
|
+
const { service, deps } = makeService();
|
|
152
|
+
deps.prismaService.user.findMany.mockResolvedValue([{ id: 1 }]);
|
|
153
|
+
await expect(service.delete('en', { ids: [1, 2] } as any)).rejects.toThrow(
|
|
154
|
+
NotFoundException,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('remove usuários e publica evento', async () => {
|
|
159
|
+
const { service, deps } = makeService();
|
|
160
|
+
deps.prismaService.user.findMany.mockResolvedValue([{ id: 1 }, { id: 2 }]);
|
|
161
|
+
deps.prismaService.user.deleteMany.mockResolvedValue({ count: 2 });
|
|
162
|
+
const result = await service.delete('en', { ids: [1, 2] } as any);
|
|
163
|
+
expect(result).toEqual({ count: 2 });
|
|
164
|
+
expect(deps.integrationApi.publishEvent).toHaveBeenCalledWith(
|
|
165
|
+
expect.objectContaining({ eventName: 'core.user.deleted' }),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('update', () => {
|
|
171
|
+
it('registra mudança quando nome altera', async () => {
|
|
172
|
+
const { service, deps } = makeService();
|
|
173
|
+
deps.prismaService.user.findUnique.mockResolvedValue({ id: 1, name: 'Old' });
|
|
174
|
+
deps.prismaService.user.update.mockResolvedValue({ id: 1, name: 'New' });
|
|
175
|
+
deps.prismaService.user_change_log.create.mockResolvedValue({ id: 1 });
|
|
176
|
+
await service.update('en', 1, { name: 'New' } as any, 9);
|
|
177
|
+
expect(deps.prismaService.user_change_log.create).toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('não registra mudança quando nome igual', async () => {
|
|
181
|
+
const { service, deps } = makeService();
|
|
182
|
+
deps.prismaService.user.findUnique.mockResolvedValue({ id: 1, name: 'Same' });
|
|
183
|
+
deps.prismaService.user.update.mockResolvedValue({ id: 1, name: 'Same' });
|
|
184
|
+
await service.update('en', 1, { name: 'Same' } as any);
|
|
185
|
+
expect(deps.prismaService.user_change_log.create).not.toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('resetPassword', () => {
|
|
190
|
+
it('cria credencial quando nenhuma existe (count 0)', async () => {
|
|
191
|
+
const { service, deps } = makeService();
|
|
192
|
+
deps.prismaService.user.findUnique.mockResolvedValue({ id: 1 });
|
|
193
|
+
deps.prismaService.user_credential.updateMany.mockResolvedValue({ count: 0 });
|
|
194
|
+
deps.prismaService.user_credential.create.mockResolvedValue({ id: 1 });
|
|
195
|
+
deps.prismaService.user_activity.create.mockResolvedValue({ id: 1 });
|
|
196
|
+
deps.prismaService.user_change_log.create.mockResolvedValue({ id: 1 });
|
|
197
|
+
|
|
198
|
+
const result = await service.resetPassword('en', 1, { password: 'MyPass1!' } as any);
|
|
199
|
+
expect(deps.prismaService.user_credential.create).toHaveBeenCalled();
|
|
200
|
+
expect(result).toEqual({ password: 'MyPass1!', requiresReset: true });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('gera senha aleatória quando não informada', async () => {
|
|
204
|
+
const { service, deps } = makeService();
|
|
205
|
+
deps.prismaService.user.findUnique.mockResolvedValue({ id: 1 });
|
|
206
|
+
deps.prismaService.user_credential.updateMany.mockResolvedValue({ count: 1 });
|
|
207
|
+
deps.prismaService.user_activity.create.mockResolvedValue({ id: 1 });
|
|
208
|
+
deps.prismaService.user_change_log.create.mockResolvedValue({ id: 1 });
|
|
209
|
+
|
|
210
|
+
const result = await service.resetPassword('en', 1, {} as any);
|
|
211
|
+
expect(result.password).toHaveLength(16);
|
|
212
|
+
expect(result.requiresReset).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('recordUserChange', () => {
|
|
217
|
+
it('serializa before/after e trata undefined como null', async () => {
|
|
218
|
+
const { service, deps } = makeService();
|
|
219
|
+
deps.prismaService.user_change_log.create.mockResolvedValue({ id: 1 });
|
|
220
|
+
await (service as any).recordUserChange({
|
|
221
|
+
targetUserId: 1,
|
|
222
|
+
action: 'update',
|
|
223
|
+
beforeData: { a: 1 },
|
|
224
|
+
});
|
|
225
|
+
const data = deps.prismaService.user_change_log.create.mock.calls[0][0].data;
|
|
226
|
+
expect(data.before_data).toBe('{"a":1}');
|
|
227
|
+
expect(data.after_data).toBeNull();
|
|
228
|
+
expect(data.actor_user_id).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('retorna null em erro do prisma', async () => {
|
|
232
|
+
const { service, deps } = makeService();
|
|
233
|
+
deps.prismaService.user_change_log.create.mockRejectedValue(new Error('x'));
|
|
234
|
+
await expect(
|
|
235
|
+
(service as any).recordUserChange({ targetUserId: 1, action: 'update' }),
|
|
236
|
+
).resolves.toBeNull();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('listUserActivities', () => {
|
|
241
|
+
it('mapeia atividades com createdAt ISO', async () => {
|
|
242
|
+
const { service, deps } = makeService();
|
|
243
|
+
deps.prismaService.user.findUnique.mockResolvedValue({ id: 1 });
|
|
244
|
+
deps.paginationService.paginatePrismaModel.mockResolvedValue({
|
|
245
|
+
data: [{ id: 7, action: 'login', created_at: new Date('2026-01-01T00:00:00Z') }],
|
|
246
|
+
total: 1,
|
|
247
|
+
});
|
|
248
|
+
const result = await service.listUserActivities('en', 1, {});
|
|
249
|
+
expect(result.data[0]).toEqual({
|
|
250
|
+
id: '7',
|
|
251
|
+
action: 'login',
|
|
252
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { getLocaleFromContext, localeStorage } from './locale-context';
|
|
3
|
+
|
|
4
|
+
describe('locale-context', () => {
|
|
5
|
+
it('retorna "en" quando não há contexto', () => {
|
|
6
|
+
expect(getLocaleFromContext()).toBe('en');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('retorna locale do storage dentro de run()', () => {
|
|
10
|
+
const result = localeStorage.run({ locale: 'pt' }, () =>
|
|
11
|
+
getLocaleFromContext(),
|
|
12
|
+
);
|
|
13
|
+
expect(result).toBe('pt');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('cai para "en" quando locale do contexto é vazio', () => {
|
|
17
|
+
const result = localeStorage.run({ locale: '' }, () =>
|
|
18
|
+
getLocaleFromContext(),
|
|
19
|
+
);
|
|
20
|
+
expect(result).toBe('en');
|
|
21
|
+
});
|
|
22
|
+
});
|