@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,78 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import { LocaleService } from './locale.service';
3
+
4
+ const makeDeps = () => {
5
+ const prisma = {
6
+ createInsensitiveSearch: jest.fn().mockReturnValue([{ name: { contains: 'x' } }]),
7
+ locale: { create: jest.fn() },
8
+ };
9
+ const pagination = { paginate: jest.fn().mockResolvedValue({ data: [] }) };
10
+ return { prisma, pagination };
11
+ };
12
+
13
+ const makeService = (deps = makeDeps()) => ({
14
+ service: new LocaleService(deps.prisma as any, deps.pagination as any),
15
+ deps,
16
+ });
17
+
18
+ describe('LocaleService', () => {
19
+ afterEach(() => jest.clearAllMocks());
20
+
21
+ describe('findAll', () => {
22
+ it('não define where.enabled quando enabled indefinido', async () => {
23
+ const { service, deps } = makeService();
24
+ await service.findAll({ page: 1, pageSize: 10 } as any);
25
+ const opts = deps.pagination.paginate.mock.calls[0][2];
26
+ expect(opts.where).not.toHaveProperty('enabled');
27
+ expect(opts.where.OR).toBeDefined();
28
+ });
29
+
30
+ it('mapeia enabled="true" para boolean true', async () => {
31
+ const { service, deps } = makeService();
32
+ await service.findAll({ page: 1, pageSize: 10 } as any, undefined, 'true');
33
+ const opts = deps.pagination.paginate.mock.calls[0][2];
34
+ expect(opts.where.enabled).toBe(true);
35
+ });
36
+
37
+ it('mapeia enabled="false" para boolean false', async () => {
38
+ const { service, deps } = makeService();
39
+ await service.findAll({ page: 1, pageSize: 10 } as any, undefined, 'false');
40
+ const opts = deps.pagination.paginate.mock.calls[0][2];
41
+ expect(opts.where.enabled).toBe(false);
42
+ });
43
+
44
+ it('usa search explícito sobre paginationDto.search', async () => {
45
+ const { service, deps } = makeService();
46
+ await service.findAll({ page: 1, pageSize: 10, search: 'dto' } as any, 'explicit');
47
+ const params = deps.prisma.createInsensitiveSearch.mock.calls[0][1];
48
+ expect(params.search).toBe('explicit');
49
+ });
50
+
51
+ it('cai para paginationDto.search quando search ausente', async () => {
52
+ const { service, deps } = makeService();
53
+ await service.findAll({ page: 1, pageSize: 10, search: 'dto' } as any);
54
+ const params = deps.prisma.createInsensitiveSearch.mock.calls[0][1];
55
+ expect(params.search).toBe('dto');
56
+ });
57
+ });
58
+
59
+ describe('create', () => {
60
+ it('aplica defaults region=null e enabled=true', async () => {
61
+ const { service, deps } = makeService();
62
+ deps.prisma.locale.create.mockResolvedValue({ id: 1 });
63
+ await service.create({ name: 'Portuguese', code: 'pt' } as any);
64
+ expect(deps.prisma.locale.create).toHaveBeenCalledWith({
65
+ data: { name: 'Portuguese', code: 'pt', region: null, enabled: true },
66
+ });
67
+ });
68
+
69
+ it('respeita region e enabled fornecidos', async () => {
70
+ const { service, deps } = makeService();
71
+ deps.prisma.locale.create.mockResolvedValue({ id: 2 });
72
+ await service.create({ name: 'X', code: 'x', region: 'BR', enabled: false } as any);
73
+ expect(deps.prisma.locale.create).toHaveBeenCalledWith({
74
+ data: { name: 'X', code: 'x', region: 'BR', enabled: false },
75
+ });
76
+ });
77
+ });
78
+ });
@@ -0,0 +1,257 @@
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ beforeEach,
6
+ afterEach,
7
+ jest,
8
+ } from '@jest/globals';
9
+ import { BadRequestException } from '@nestjs/common';
10
+ import { MailService } from './mail.service';
11
+
12
+ const makeDeps = () => {
13
+ const prismaService = {
14
+ mail: {
15
+ findUnique: jest.fn(),
16
+ create: jest.fn(),
17
+ deleteMany: jest.fn(),
18
+ },
19
+ mail_locale: { deleteMany: jest.fn(), createMany: jest.fn() },
20
+ mail_var: {
21
+ deleteMany: jest.fn(),
22
+ createMany: jest.fn(),
23
+ findMany: jest.fn(),
24
+ },
25
+ locale: { findMany: jest.fn() },
26
+ };
27
+ const mailMainService = { send: jest.fn(), setConfig: jest.fn() };
28
+ const localeService = {};
29
+ const setting = { getSettingValues: jest.fn(), setValue: jest.fn() };
30
+ const integrationApi = { subscribe: jest.fn() };
31
+ return { prismaService, mailMainService, localeService, setting, integrationApi };
32
+ };
33
+
34
+ const makeService = (deps = makeDeps()) => ({
35
+ service: new MailService(
36
+ deps.prismaService as any,
37
+ deps.mailMainService as any,
38
+ deps.localeService as any,
39
+ deps.setting as any,
40
+ deps.integrationApi as any,
41
+ ),
42
+ deps,
43
+ });
44
+
45
+ describe('MailService', () => {
46
+ afterEach(() => {
47
+ jest.clearAllMocks();
48
+ });
49
+
50
+ describe('interpolateTemplate', () => {
51
+ const call = (src: string, data: any) =>
52
+ (makeService().service as any).interpolateTemplate(src, data);
53
+
54
+ it('interpola variáveis handlebars', () => {
55
+ expect(call('Hello {{name}}', { name: 'Bob' })).toBe('Hello Bob');
56
+ });
57
+
58
+ it('converte quebras de linha em <br/>', () => {
59
+ expect(call('Line1\nLine2', {})).toBe('Line1<br/>Line2');
60
+ expect(call('A\r\nB', {})).toBe('A<br/>B');
61
+ });
62
+
63
+ it('lança BadRequestException em sintaxe inválida', () => {
64
+ expect(() => call('{{#if foo}}sem fechar', {})).toThrow(
65
+ BadRequestException,
66
+ );
67
+ });
68
+ });
69
+
70
+ describe('isSettingEnabled', () => {
71
+ const call = (v: unknown) =>
72
+ (makeService().service as any).isSettingEnabled(v);
73
+
74
+ it('reconhece true booleano e string "true"', () => {
75
+ expect(call(true)).toBe(true);
76
+ expect(call('true')).toBe(true);
77
+ });
78
+
79
+ it('rejeita outros valores', () => {
80
+ expect(call(false)).toBe(false);
81
+ expect(call('false')).toBe(false);
82
+ expect(call('1')).toBe(false);
83
+ expect(call(undefined)).toBe(false);
84
+ expect(call(null)).toBe(false);
85
+ });
86
+ });
87
+
88
+ describe('isMailConfigSlug', () => {
89
+ const call = (v: string) =>
90
+ (makeService().service as any).isMailConfigSlug(v);
91
+
92
+ it('reconhece o slug de config de email', () => {
93
+ expect(call('mail-integration-profile-id')).toBe(true);
94
+ });
95
+
96
+ it('rejeita outros slugs', () => {
97
+ expect(call('other-setting')).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe('onModuleInit', () => {
102
+ it('registra o subscriber de core.setting.changed', () => {
103
+ const { service, deps } = makeService();
104
+ service.onModuleInit();
105
+ expect(deps.integrationApi.subscribe).toHaveBeenCalledWith(
106
+ expect.objectContaining({
107
+ eventName: 'core.setting.changed',
108
+ consumerName: 'core.mail-config-validator',
109
+ }),
110
+ );
111
+ });
112
+ });
113
+
114
+ describe('create', () => {
115
+ it('lança BadRequest quando slug já existe', async () => {
116
+ const { service, deps } = makeService();
117
+ deps.prismaService.mail.findUnique.mockResolvedValue({ id: 1 });
118
+ await expect(
119
+ service.create({ slug: 'dup' } as any),
120
+ ).rejects.toThrow(BadRequestException);
121
+ });
122
+
123
+ it('cria template quando slug é novo', async () => {
124
+ const { service, deps } = makeService();
125
+ deps.prismaService.mail.findUnique.mockResolvedValue(null);
126
+ deps.prismaService.mail.create.mockResolvedValue({ id: 2, slug: 'novo' });
127
+ await expect(
128
+ service.create({ slug: 'novo' } as any),
129
+ ).resolves.toMatchObject({ id: 2 });
130
+ });
131
+ });
132
+
133
+ describe('delete', () => {
134
+ it('lança BadRequest quando ids ausente', async () => {
135
+ const { service } = makeService();
136
+ await expect(service.delete({} as any)).rejects.toThrow(
137
+ BadRequestException,
138
+ );
139
+ });
140
+
141
+ it('deleta pelos ids fornecidos', async () => {
142
+ const { service, deps } = makeService();
143
+ deps.prismaService.mail.deleteMany.mockResolvedValue({ count: 2 });
144
+ await expect(service.delete({ ids: [1, 2] } as any)).resolves.toEqual({
145
+ count: 2,
146
+ });
147
+ expect(deps.prismaService.mail.deleteMany).toHaveBeenCalledWith({
148
+ where: { id: { in: [1, 2] } },
149
+ });
150
+ });
151
+ });
152
+
153
+ describe('importTemplates', () => {
154
+ it('retorna conflitos por slug quando overwrite=false', async () => {
155
+ const { service, deps } = makeService();
156
+ deps.prismaService.mail.findUnique.mockResolvedValue({ id: 5 });
157
+ const result = await service.importTemplates({
158
+ overwrite: false,
159
+ data: [{ slug: 'welcome', translations: [], variables: [] }],
160
+ } as any);
161
+ expect(result).toEqual({ conflicts: ['welcome'] });
162
+ });
163
+
164
+ it('cria template novo quando não existe', async () => {
165
+ const { service, deps } = makeService();
166
+ // 1a checagem de conflito: null; 2a checagem dentro do loop: null
167
+ deps.prismaService.mail.findUnique.mockResolvedValue(null);
168
+ deps.prismaService.locale.findMany.mockResolvedValue([
169
+ { code: 'en', id: 1 },
170
+ ]);
171
+ deps.prismaService.mail.create.mockResolvedValue({ id: 10 });
172
+
173
+ const result = await service.importTemplates({
174
+ overwrite: false,
175
+ data: [
176
+ {
177
+ slug: 'welcome',
178
+ translations: [{ code: 'en', subject: 'Hi', body: 'Body' }],
179
+ variables: ['name'],
180
+ },
181
+ ],
182
+ } as any);
183
+
184
+ expect(result).toEqual({
185
+ success: true,
186
+ imported: 1,
187
+ templates: [10],
188
+ });
189
+ expect(deps.prismaService.mail.create).toHaveBeenCalled();
190
+ });
191
+
192
+ it('sobrescreve template existente quando overwrite=true', async () => {
193
+ const { service, deps } = makeService();
194
+ deps.prismaService.mail.findUnique.mockResolvedValue({ id: 7 });
195
+ deps.prismaService.locale.findMany.mockResolvedValue([
196
+ { code: 'en', id: 1 },
197
+ ]);
198
+ deps.prismaService.mail_locale.deleteMany.mockResolvedValue({});
199
+ deps.prismaService.mail_var.deleteMany.mockResolvedValue({});
200
+ deps.prismaService.mail_locale.createMany.mockResolvedValue({});
201
+ deps.prismaService.mail_var.createMany.mockResolvedValue({});
202
+
203
+ const result = await service.importTemplates({
204
+ overwrite: true,
205
+ data: [
206
+ {
207
+ slug: 'welcome',
208
+ translations: [{ code: 'en', subject: 'Hi', body: 'Body' }],
209
+ variables: ['name'],
210
+ },
211
+ ],
212
+ } as any);
213
+
214
+ expect(result).toEqual({
215
+ success: true,
216
+ imported: 1,
217
+ templates: [7],
218
+ });
219
+ expect(deps.prismaService.mail_locale.deleteMany).toHaveBeenCalledWith({
220
+ where: { mail_id: 7 },
221
+ });
222
+ expect(deps.prismaService.mail_locale.createMany).toHaveBeenCalled();
223
+ expect(deps.prismaService.mail.create).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it('lança BadRequest quando ocorre erro ao importar', async () => {
227
+ const { service, deps } = makeService();
228
+ deps.prismaService.mail.findUnique.mockResolvedValue(null);
229
+ deps.prismaService.locale.findMany.mockRejectedValue(
230
+ new Error('db down'),
231
+ );
232
+ await expect(
233
+ service.importTemplates({
234
+ overwrite: true,
235
+ data: [{ slug: 'welcome', translations: [], variables: [] }],
236
+ } as any),
237
+ ).rejects.toThrow(BadRequestException);
238
+ });
239
+ });
240
+
241
+ describe('sendTemplatedMail', () => {
242
+ it('aborta silenciosamente quando mail não configurado', async () => {
243
+ const { service, deps } = makeService();
244
+ deps.setting.getSettingValues.mockResolvedValue({
245
+ 'configured-mail': 'false',
246
+ });
247
+ await expect(
248
+ service.sendTemplatedMail('en', {
249
+ email: 'a@b.com',
250
+ slug: 's',
251
+ variables: {},
252
+ } as any),
253
+ ).resolves.toBeUndefined();
254
+ expect(deps.mailMainService.send).not.toHaveBeenCalled();
255
+ });
256
+ });
257
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import { UnauthorizedException } from '@nestjs/common';
3
+ import { McpAuthGuard } from './mcp-auth.guard';
4
+
5
+ const makeContext = (request: any) => ({
6
+ switchToHttp: () => ({ getRequest: () => request }),
7
+ });
8
+
9
+ const makeDeps = () => {
10
+ const tokenService = { verify: jest.fn() };
11
+ const prisma = { mcp_api_key: { findFirst: jest.fn(), update: jest.fn() } };
12
+ const security = { hashWithPepper: jest.fn().mockReturnValue('hash') };
13
+ return { tokenService, prisma, security };
14
+ };
15
+
16
+ const makeService = (deps = makeDeps()) => ({
17
+ guard: new McpAuthGuard(
18
+ deps.tokenService as any,
19
+ deps.prisma as any,
20
+ deps.security as any,
21
+ ),
22
+ deps,
23
+ });
24
+
25
+ describe('McpAuthGuard', () => {
26
+ afterEach(() => jest.clearAllMocks());
27
+
28
+ it('nega sem token', async () => {
29
+ const { guard } = makeService();
30
+ await expect(
31
+ guard.canActivate(makeContext({ headers: {} }) as any),
32
+ ).rejects.toThrow(UnauthorizedException);
33
+ });
34
+
35
+ it('verifica token JWT normal', async () => {
36
+ const { guard, deps } = makeService();
37
+ deps.tokenService.verify.mockResolvedValue({ sub: 4 });
38
+ const request: any = { headers: { authorization: 'Bearer tok' } };
39
+ await expect(guard.canActivate(makeContext(request) as any)).resolves.toBe(
40
+ true,
41
+ );
42
+ expect(request.auth).toEqual({ sub: 4 });
43
+ });
44
+
45
+ it('autentica com api key mcp válida', async () => {
46
+ const { guard, deps } = makeService();
47
+ deps.prisma.mcp_api_key.findFirst.mockResolvedValue({
48
+ id: 1,
49
+ user_id: 8,
50
+ expires_at: null,
51
+ });
52
+ deps.prisma.mcp_api_key.update.mockResolvedValue({});
53
+ const request: any = {
54
+ headers: { authorization: 'Bearer hedhog_mcp_x' },
55
+ };
56
+ await expect(guard.canActivate(makeContext(request) as any)).resolves.toBe(
57
+ true,
58
+ );
59
+ expect(request.auth).toEqual({ sub: 8, sessionId: null });
60
+ });
61
+
62
+ it('nega api key mcp inexistente', async () => {
63
+ const { guard, deps } = makeService();
64
+ deps.prisma.mcp_api_key.findFirst.mockResolvedValue(null);
65
+ const request: any = {
66
+ headers: { authorization: 'Bearer hedhog_mcp_x' },
67
+ };
68
+ await expect(guard.canActivate(makeContext(request) as any)).rejects.toThrow(
69
+ 'MCP: Invalid API key',
70
+ );
71
+ });
72
+
73
+ it('nega api key mcp expirada', async () => {
74
+ const { guard, deps } = makeService();
75
+ deps.prisma.mcp_api_key.findFirst.mockResolvedValue({
76
+ id: 1,
77
+ user_id: 8,
78
+ expires_at: new Date(Date.now() - 1000),
79
+ });
80
+ const request: any = {
81
+ headers: { authorization: 'Bearer hedhog_mcp_x' },
82
+ };
83
+ await expect(guard.canActivate(makeContext(request) as any)).rejects.toThrow(
84
+ 'MCP: API key expired',
85
+ );
86
+ });
87
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import { ForbiddenException } from '@nestjs/common';
3
+ import { McpPermissionGuard } from './mcp-permission.guard';
4
+
5
+ const makeContext = (request: any) => ({
6
+ switchToHttp: () => ({ getRequest: () => request }),
7
+ });
8
+
9
+ const makeService = () => {
10
+ const prisma = { route: { count: jest.fn() } };
11
+ return { guard: new McpPermissionGuard(prisma as any), prisma };
12
+ };
13
+
14
+ describe('McpPermissionGuard', () => {
15
+ afterEach(() => jest.clearAllMocks());
16
+
17
+ it('permite métodos não-POST', async () => {
18
+ const { guard } = makeService();
19
+ await expect(
20
+ guard.canActivate(makeContext({ method: 'GET' }) as any),
21
+ ).resolves.toBe(true);
22
+ });
23
+
24
+ it('permite POST sem tools/call (ex.: tools/list)', async () => {
25
+ const { guard, prisma } = makeService();
26
+ await expect(
27
+ guard.canActivate(
28
+ makeContext({ method: 'POST', body: { method: 'tools/list' } }) as any,
29
+ ),
30
+ ).resolves.toBe(true);
31
+ expect(prisma.route.count).not.toHaveBeenCalled();
32
+ });
33
+
34
+ it('permite tools/call quando usuário tem permissão', async () => {
35
+ const { guard, prisma } = makeService();
36
+ prisma.route.count.mockResolvedValue(1);
37
+ const request = {
38
+ method: 'POST',
39
+ body: { method: 'tools/call', params: { name: 'my_tool' } },
40
+ auth: { sub: 2 },
41
+ };
42
+ await expect(guard.canActivate(makeContext(request) as any)).resolves.toBe(
43
+ true,
44
+ );
45
+ expect(prisma.route.count).toHaveBeenCalledWith(
46
+ expect.objectContaining({
47
+ where: expect.objectContaining({ tool_name: 'my_tool' }),
48
+ }),
49
+ );
50
+ });
51
+
52
+ it('nega tools/call sem permissão', async () => {
53
+ const { guard, prisma } = makeService();
54
+ prisma.route.count.mockResolvedValue(0);
55
+ const request = {
56
+ method: 'POST',
57
+ body: { method: 'tools/call', params: { name: 'secret_tool' } },
58
+ auth: { sub: 2 },
59
+ };
60
+ await expect(guard.canActivate(makeContext(request) as any)).rejects.toThrow(
61
+ ForbiddenException,
62
+ );
63
+ });
64
+ });
@@ -0,0 +1,125 @@
1
+ import { NotFoundException } from '@nestjs/common';
2
+ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
3
+ import { McpApiKeyService } from './mcp-api-key.service';
4
+
5
+ const makeService = () => {
6
+ const prisma = {
7
+ mcp_api_key: {
8
+ create: jest.fn(),
9
+ findFirst: jest.fn(),
10
+ update: jest.fn(),
11
+ },
12
+ };
13
+ const security = {
14
+ randomOpaque: jest.fn(() => 'abcdefghijklmnopqrstuvwxyz0123456789'),
15
+ hashWithPepper: jest.fn((v: string) => `hash(${v})`),
16
+ };
17
+ const pagination = {
18
+ paginatePrismaModel: jest.fn(),
19
+ };
20
+ const service = new McpApiKeyService(prisma as any, security as any, pagination as any);
21
+ return { service, prisma, security, pagination };
22
+ };
23
+
24
+ describe('McpApiKeyService', () => {
25
+ afterEach(() => {
26
+ jest.clearAllMocks();
27
+ });
28
+
29
+ describe('list', () => {
30
+ it('pagina apenas as chaves ativas do usuário e devolve o resultado', async () => {
31
+ const { service, prisma, pagination } = makeService();
32
+ const paginated = { data: [], total: 0 };
33
+ pagination.paginatePrismaModel.mockResolvedValue(paginated);
34
+
35
+ const result = await service.list(42, { page: 1, pageSize: 10 } as any);
36
+
37
+ expect(result).toBe(paginated);
38
+ expect(pagination.paginatePrismaModel).toHaveBeenCalledWith(
39
+ prisma.mcp_api_key,
40
+ expect.objectContaining({
41
+ page: 1,
42
+ pageSize: 10,
43
+ where: { user_id: 42, revoked_at: null },
44
+ orderBy: { created_at: 'desc' },
45
+ }),
46
+ );
47
+ });
48
+ });
49
+
50
+ describe('create', () => {
51
+ it('usa o prefixo mcp por padrão e monta hash/prefixo do token', async () => {
52
+ const { service, prisma, security } = makeService();
53
+ prisma.mcp_api_key.create.mockResolvedValue({
54
+ id: 1,
55
+ name: 'my key',
56
+ type: 'mcp',
57
+ token_prefix: 'hedhog_mcp_abcde',
58
+ created_at: new Date('2026-01-01'),
59
+ });
60
+
61
+ const result = await service.create(7, 'my key');
62
+
63
+ const rawToken = 'hedhog_mcp_abcdefghijklmnopqrstuvwxyz0123456789';
64
+ expect(security.randomOpaque).toHaveBeenCalledWith(36);
65
+ expect(security.hashWithPepper).toHaveBeenCalledWith(rawToken);
66
+ expect(prisma.mcp_api_key.create).toHaveBeenCalledWith(
67
+ expect.objectContaining({
68
+ data: {
69
+ user_id: 7,
70
+ name: 'my key',
71
+ type: 'mcp',
72
+ token_hash: `hash(${rawToken})`,
73
+ token_prefix: 'hedhog_mcp_abcde',
74
+ },
75
+ }),
76
+ );
77
+ // O rawToken (em claro) só é devolvido uma vez, na criação.
78
+ expect(result.rawToken).toBe(rawToken);
79
+ expect(result.id).toBe(1);
80
+ });
81
+
82
+ it('usa o prefixo api quando type = api', async () => {
83
+ const { service, prisma } = makeService();
84
+ prisma.mcp_api_key.create.mockResolvedValue({ id: 2 });
85
+
86
+ const result = await service.create(7, 'api key', 'api');
87
+
88
+ expect(result.rawToken).toBe('hedhog_api_abcdefghijklmnopqrstuvwxyz0123456789');
89
+ expect(prisma.mcp_api_key.create).toHaveBeenCalledWith(
90
+ expect.objectContaining({
91
+ data: expect.objectContaining({ type: 'api', token_prefix: 'hedhog_api_abcde' }),
92
+ }),
93
+ );
94
+ });
95
+ });
96
+
97
+ describe('revoke', () => {
98
+ it('lança NotFoundException quando a chave não existe para o usuário', async () => {
99
+ const { service, prisma } = makeService();
100
+ prisma.mcp_api_key.findFirst.mockResolvedValue(null);
101
+
102
+ await expect(service.revoke(7, 99)).rejects.toThrow(NotFoundException);
103
+ expect(prisma.mcp_api_key.update).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('marca revoked_at e retorna success quando a chave existe', async () => {
107
+ const { service, prisma } = makeService();
108
+ prisma.mcp_api_key.findFirst.mockResolvedValue({ id: 5, user_id: 7 });
109
+ prisma.mcp_api_key.update.mockResolvedValue({});
110
+
111
+ const result = await service.revoke(7, 5);
112
+
113
+ expect(prisma.mcp_api_key.findFirst).toHaveBeenCalledWith({
114
+ where: { id: 5, user_id: 7 },
115
+ });
116
+ expect(prisma.mcp_api_key.update).toHaveBeenCalledWith(
117
+ expect.objectContaining({
118
+ where: { id: 5 },
119
+ data: expect.objectContaining({ revoked_at: expect.any(Date) }),
120
+ }),
121
+ );
122
+ expect(result).toEqual({ success: true });
123
+ });
124
+ });
125
+ });