@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,289 @@
1
+ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
2
+
3
+ const registerIntegrationConfigTransformer = jest.fn();
4
+
5
+ jest.mock('@hed-hog/api-prisma', () => ({
6
+ registerIntegrationConfigTransformer: (...args: any[]) =>
7
+ registerIntegrationConfigTransformer(...args),
8
+ }));
9
+
10
+ import {
11
+ IntegrationCredentialCryptoService,
12
+ ENC_ENVELOPE_PREFIX,
13
+ SECRET_MASK,
14
+ } from './integration-credential-crypto.service';
15
+
16
+ // Cifra reversível determinística (hex) usada como stub do SecurityService.
17
+ const makeSecurity = () => ({
18
+ encrypt: jest.fn((value: string) => Buffer.from(value, 'utf8').toString('hex')),
19
+ decrypt: jest.fn((value: string) => Buffer.from(value, 'hex').toString('utf8')),
20
+ });
21
+
22
+ const makeService = (security = makeSecurity()) => {
23
+ const service = new IntegrationCredentialCryptoService(security as any);
24
+ return { service, security };
25
+ };
26
+
27
+ describe('IntegrationCredentialCryptoService', () => {
28
+ afterEach(() => {
29
+ jest.clearAllMocks();
30
+ });
31
+
32
+ describe('onModuleInit', () => {
33
+ it('registra o transformer de decifra transparente', () => {
34
+ const { service } = makeService();
35
+ service.onModuleInit();
36
+ expect(registerIntegrationConfigTransformer).toHaveBeenCalledTimes(1);
37
+ expect(registerIntegrationConfigTransformer).toHaveBeenCalledWith(
38
+ expect.any(Function),
39
+ );
40
+ });
41
+
42
+ it('o transformer registrado delega para decryptConfig', () => {
43
+ const { service } = makeService();
44
+ const spy = jest
45
+ .spyOn(service, 'decryptConfig')
46
+ .mockReturnValue({ ok: true } as any);
47
+ service.onModuleInit();
48
+ const transformer = registerIntegrationConfigTransformer.mock
49
+ .calls[0][0] as (c: any) => any;
50
+ const out = transformer({ some: 'config' });
51
+ expect(spy).toHaveBeenCalledWith({ some: 'config' });
52
+ expect(out).toEqual({ ok: true });
53
+ });
54
+ });
55
+
56
+ describe('isEnveloped', () => {
57
+ it('true apenas para strings com o prefixo do envelope', () => {
58
+ const { service } = makeService();
59
+ const isEnveloped = (v: unknown) => (service as any).isEnveloped(v);
60
+ expect(isEnveloped(`${ENC_ENVELOPE_PREFIX}abc`)).toBe(true);
61
+ expect(isEnveloped('abc')).toBe(false);
62
+ expect(isEnveloped(123)).toBe(false);
63
+ expect(isEnveloped(null)).toBe(false);
64
+ expect(isEnveloped(undefined)).toBe(false);
65
+ expect(isEnveloped({})).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe('encryptValue', () => {
70
+ it('cifra um valor simples com o envelope', () => {
71
+ const { service, security } = makeService();
72
+ const out = service.encryptValue('hello');
73
+ expect(out.startsWith(ENC_ENVELOPE_PREFIX)).toBe(true);
74
+ expect(security.encrypt).toHaveBeenCalledWith('hello');
75
+ expect(out).toBe(ENC_ENVELOPE_PREFIX + Buffer.from('hello').toString('hex'));
76
+ });
77
+
78
+ it('é idempotente: não recifra um valor já com envelope', () => {
79
+ const { service, security } = makeService();
80
+ const already = `${ENC_ENVELOPE_PREFIX}deadbeef`;
81
+ expect(service.encryptValue(already)).toBe(already);
82
+ expect(security.encrypt).not.toHaveBeenCalled();
83
+ });
84
+ });
85
+
86
+ describe('decryptValue', () => {
87
+ it('decifra um valor com envelope', () => {
88
+ const { service, security } = makeService();
89
+ const enveloped = ENC_ENVELOPE_PREFIX + Buffer.from('secret').toString('hex');
90
+ expect(service.decryptValue(enveloped)).toBe('secret');
91
+ expect(security.decrypt).toHaveBeenCalledWith(
92
+ Buffer.from('secret').toString('hex'),
93
+ );
94
+ });
95
+
96
+ it('passa valores sem envelope intactos (legado)', () => {
97
+ const { service, security } = makeService();
98
+ expect(service.decryptValue('plain-legacy')).toBe('plain-legacy');
99
+ expect(service.decryptValue(42)).toBe(42);
100
+ expect(service.decryptValue(null)).toBe(null);
101
+ expect(security.decrypt).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('em erro de decifra loga e devolve o valor como veio (não derruba a query)', () => {
105
+ const security = makeSecurity();
106
+ security.decrypt.mockImplementation(() => {
107
+ throw new Error('ENCRYPTION_SECRET trocado');
108
+ });
109
+ const { service } = makeService(security);
110
+ const enveloped = `${ENC_ENVELOPE_PREFIX}whatever`;
111
+ const errSpy = jest
112
+ .spyOn((service as any).logger, 'error')
113
+ .mockImplementation(() => undefined);
114
+ expect(service.decryptValue(enveloped)).toBe(enveloped);
115
+ expect(errSpy).toHaveBeenCalled();
116
+ });
117
+ });
118
+
119
+ describe('round-trip', () => {
120
+ it('encryptValue -> decryptValue devolve o valor original', () => {
121
+ const { service } = makeService();
122
+ const original = 'sk-live-1234567890';
123
+ expect(service.decryptValue(service.encryptValue(original))).toBe(original);
124
+ });
125
+ });
126
+
127
+ describe('encryptConfig', () => {
128
+ it('cifra apenas as chaves sensíveis do provider', () => {
129
+ const { service } = makeService();
130
+ const out = service.encryptConfig('stripe', {
131
+ secret_key: 'sk_test',
132
+ webhook_secret: 'whsec',
133
+ public_key: 'pk_test',
134
+ }) as Record<string, string>;
135
+ expect(out.secret_key.startsWith(ENC_ENVELOPE_PREFIX)).toBe(true);
136
+ expect(out.webhook_secret.startsWith(ENC_ENVELOPE_PREFIX)).toBe(true);
137
+ // Campo público não é tocado.
138
+ expect(out.public_key).toBe('pk_test');
139
+ });
140
+
141
+ it('ignora valores vazios e não-string', () => {
142
+ const { service } = makeService();
143
+ const out = service.encryptConfig('smtp', {
144
+ password: '',
145
+ }) as Record<string, string>;
146
+ expect(out.password).toBe('');
147
+ });
148
+
149
+ it('provider desconhecido retorna o config inalterado (mesma referência)', () => {
150
+ const { service } = makeService();
151
+ const config = { api_key: 'abc' };
152
+ expect(service.encryptConfig('does-not-exist', config)).toBe(config);
153
+ });
154
+
155
+ it('é case-insensitive no slug do provider', () => {
156
+ const { service } = makeService();
157
+ const out = service.encryptConfig('OpenAI', {
158
+ api_key: 'sk-abc',
159
+ }) as Record<string, string>;
160
+ expect(out.api_key.startsWith(ENC_ENVELOPE_PREFIX)).toBe(true);
161
+ });
162
+
163
+ it('config null/undefined passa intacto', () => {
164
+ const { service } = makeService();
165
+ expect(service.encryptConfig('stripe', null)).toBeNull();
166
+ expect(service.encryptConfig('stripe', undefined)).toBeUndefined();
167
+ });
168
+
169
+ it('é idempotente sobre valores já cifrados', () => {
170
+ const { service } = makeService();
171
+ const once = service.encryptConfig('openai', { api_key: 'sk-abc' }) as any;
172
+ const twice = service.encryptConfig('openai', once) as any;
173
+ expect(twice.api_key).toBe(once.api_key);
174
+ });
175
+ });
176
+
177
+ describe('decryptConfig', () => {
178
+ it('decifra as chaves com envelope e devolve novo objeto', () => {
179
+ const { service } = makeService();
180
+ const encrypted = service.encryptConfig('openai', {
181
+ api_key: 'sk-secret',
182
+ base_url: 'https://api',
183
+ }) as any;
184
+ const decrypted = service.decryptConfig(encrypted) as Record<string, string>;
185
+ expect(decrypted.api_key).toBe('sk-secret');
186
+ expect(decrypted.base_url).toBe('https://api');
187
+ expect(decrypted).not.toBe(encrypted);
188
+ });
189
+
190
+ it('retorna a mesma referência quando nada mudou', () => {
191
+ const { service } = makeService();
192
+ const config = { public: 'value', other: 1 };
193
+ expect(service.decryptConfig(config)).toBe(config);
194
+ });
195
+
196
+ it('config null passa intacto', () => {
197
+ const { service } = makeService();
198
+ expect(service.decryptConfig(null)).toBeNull();
199
+ });
200
+ });
201
+
202
+ describe('maskConfig', () => {
203
+ it('mascara os segredos do provider com o placeholder padrão', () => {
204
+ const { service } = makeService();
205
+ const out = service.maskConfig('stripe', {
206
+ secret_key: 'sk_test',
207
+ public_key: 'pk_test',
208
+ }) as Record<string, string>;
209
+ expect(out.secret_key).toBe(SECRET_MASK);
210
+ expect(out.public_key).toBe('pk_test');
211
+ });
212
+
213
+ it('aceita placeholder customizado e ignora vazios', () => {
214
+ const { service } = makeService();
215
+ const out = service.maskConfig(
216
+ 'stripe',
217
+ { secret_key: 'sk', webhook_secret: '' },
218
+ 'REDACTED',
219
+ ) as Record<string, string>;
220
+ expect(out.secret_key).toBe('REDACTED');
221
+ expect(out.webhook_secret).toBe('');
222
+ });
223
+
224
+ it('provider sem segredos retorna o config inalterado', () => {
225
+ const { service } = makeService();
226
+ const config = { foo: 'bar' };
227
+ expect(service.maskConfig('unknown', config)).toBe(config);
228
+ });
229
+
230
+ it('config null passa intacto', () => {
231
+ const { service } = makeService();
232
+ expect(service.maskConfig('stripe', null)).toBeNull();
233
+ });
234
+ });
235
+
236
+ describe('mergeKeepingStoredSecrets', () => {
237
+ it('herda o segredo armazenado quando o recebido é o placeholder de máscara', () => {
238
+ const { service } = makeService();
239
+ const merged = service.mergeKeepingStoredSecrets(
240
+ 'stripe',
241
+ { secret_key: SECRET_MASK, webhook_secret: SECRET_MASK },
242
+ { secret_key: 'stored_sk', webhook_secret: 'stored_whsec' },
243
+ ) as Record<string, string>;
244
+ expect(merged.secret_key).toBe('stored_sk');
245
+ expect(merged.webhook_secret).toBe('stored_whsec');
246
+ });
247
+
248
+ it('herda quando o recebido está vazio/undefined/null', () => {
249
+ const { service } = makeService();
250
+ const merged = service.mergeKeepingStoredSecrets(
251
+ 'mercado_pago',
252
+ { access_token: '', webhook_secret: undefined },
253
+ { access_token: 'stored_at', webhook_secret: 'stored_ws' },
254
+ ) as Record<string, string>;
255
+ expect(merged.access_token).toBe('stored_at');
256
+ expect(merged.webhook_secret).toBe('stored_ws');
257
+ });
258
+
259
+ it('mantém o valor recebido quando o usuário informou um novo segredo', () => {
260
+ const { service } = makeService();
261
+ const merged = service.mergeKeepingStoredSecrets(
262
+ 'openai',
263
+ { api_key: 'new-key' },
264
+ { api_key: 'old-key' },
265
+ ) as Record<string, string>;
266
+ expect(merged.api_key).toBe('new-key');
267
+ });
268
+
269
+ it('não herda quando não há valor armazenado (mantém blank)', () => {
270
+ const { service } = makeService();
271
+ const merged = service.mergeKeepingStoredSecrets(
272
+ 'openai',
273
+ { api_key: SECRET_MASK },
274
+ {},
275
+ ) as Record<string, string>;
276
+ expect(merged.api_key).toBe(SECRET_MASK);
277
+ });
278
+
279
+ it('lida com incoming/stored ausentes sem quebrar', () => {
280
+ const { service } = makeService();
281
+ const merged = service.mergeKeepingStoredSecrets(
282
+ 'openai',
283
+ null,
284
+ null,
285
+ ) as Record<string, string>;
286
+ expect(merged).toEqual({});
287
+ });
288
+ });
289
+ });
@@ -33,6 +33,10 @@ export const INTEGRATION_SECRET_KEYS: Record<string, string[]> = {
33
33
  'microsoft-oauth': ['client_secret'],
34
34
  'facebook-oauth': ['client_secret'],
35
35
  'microsoft-entra-id-oauth': ['client_secret'],
36
+ // Apple has no static client_secret — the private_key signs a short-lived JWT
37
+ // client_secret at request time (see AppleProvider), so private_key is the secret.
38
+ 'apple-oauth': ['private_key'],
39
+ 'linkedin-oauth': ['client_secret'],
36
40
  // Pagamento
37
41
  stripe: ['secret_key', 'webhook_secret'],
38
42
  mercado_pago: ['access_token', 'webhook_secret'],
@@ -0,0 +1,332 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { BadRequestException } from '@nestjs/common';
3
+ import { EnumProvider } from '../file/provider/provider.enum';
4
+ import {
5
+ buildMailConfigFromIntegration,
6
+ getFromAddress,
7
+ getReplyToAddress,
8
+ buildStorageConfigFromIntegration,
9
+ resolveStorageEnumProvider,
10
+ resolveFileProviderSlug,
11
+ buildOAuthConfigFromIntegration,
12
+ buildDigitalOceanConfigFromIntegration,
13
+ buildKubernetesConfigFromIntegration,
14
+ buildAiConfigFromIntegration,
15
+ } from './integration-profile.utils';
16
+
17
+ describe('integration-profile.utils', () => {
18
+ describe('buildMailConfigFromIntegration', () => {
19
+ it('mapeia smtp com valores fornecidos', () => {
20
+ const result = buildMailConfigFromIntegration('smtp', {
21
+ host: 'mail.example.com',
22
+ port: 465,
23
+ secure: true,
24
+ username: 'user',
25
+ password: 'pass',
26
+ });
27
+
28
+ expect(result).toEqual({
29
+ global: true,
30
+ type: 'SMTP',
31
+ host: 'mail.example.com',
32
+ port: 465,
33
+ secure: true,
34
+ username: 'user',
35
+ password: 'pass',
36
+ });
37
+ });
38
+
39
+ it('aplica defaults de smtp quando config vazia', () => {
40
+ const result = buildMailConfigFromIntegration('SMTP', {});
41
+ expect(result).toMatchObject({
42
+ type: 'SMTP',
43
+ host: '',
44
+ port: 587,
45
+ secure: false,
46
+ username: '',
47
+ password: '',
48
+ });
49
+ });
50
+
51
+ it('trata config null aplicando defaults', () => {
52
+ const result = buildMailConfigFromIntegration('smtp', null);
53
+ expect(result.host).toBe('');
54
+ expect(result.port).toBe(587);
55
+ });
56
+
57
+ it('mapeia gmail', () => {
58
+ const result = buildMailConfigFromIntegration('gmail', {
59
+ client_id: 'cid',
60
+ client_secret: 'secret',
61
+ refresh_token: 'rt',
62
+ from_email: 'a@b.com',
63
+ });
64
+ expect(result).toEqual({
65
+ global: true,
66
+ type: 'GMAIL',
67
+ clientId: 'cid',
68
+ clientSecret: 'secret',
69
+ refreshToken: 'rt',
70
+ from: 'a@b.com',
71
+ });
72
+ });
73
+
74
+ it('mapeia ses', () => {
75
+ const result = buildMailConfigFromIntegration('ses', {
76
+ region: 'us-east-1',
77
+ access_key_id: 'ak',
78
+ secret_access_key: 'sk',
79
+ from_email: 'a@b.com',
80
+ });
81
+ expect(result).toEqual({
82
+ global: true,
83
+ type: 'SES',
84
+ region: 'us-east-1',
85
+ accessKeyId: 'ak',
86
+ secretAccessKey: 'sk',
87
+ from: 'a@b.com',
88
+ });
89
+ });
90
+
91
+ it('lança BadRequestException para provider desconhecido', () => {
92
+ expect(() => buildMailConfigFromIntegration('mailgun', {})).toThrow(
93
+ BadRequestException,
94
+ );
95
+ });
96
+ });
97
+
98
+ describe('getFromAddress', () => {
99
+ it('formata com nome e email', () => {
100
+ expect(
101
+ getFromAddress({ from_email: 'a@b.com', from_name: 'Alice' }),
102
+ ).toBe('Alice <a@b.com>');
103
+ });
104
+
105
+ it('retorna apenas email quando sem nome', () => {
106
+ expect(getFromAddress({ from_email: 'a@b.com' })).toBe('a@b.com');
107
+ });
108
+
109
+ it('trata config null', () => {
110
+ expect(getFromAddress(null)).toBe('');
111
+ });
112
+ });
113
+
114
+ describe('getReplyToAddress', () => {
115
+ it('retorna undefined quando reply-to vazio', () => {
116
+ expect(getReplyToAddress({})).toBeUndefined();
117
+ expect(getReplyToAddress({ reply_to_email: ' ' })).toBeUndefined();
118
+ });
119
+
120
+ it('formata com nome', () => {
121
+ expect(
122
+ getReplyToAddress({ reply_to_email: 'r@b.com', reply_to_name: 'Bob' }),
123
+ ).toBe('Bob <r@b.com>');
124
+ });
125
+
126
+ it('retorna apenas email quando sem nome', () => {
127
+ expect(getReplyToAddress({ reply_to_email: 'r@b.com' })).toBe('r@b.com');
128
+ });
129
+ });
130
+
131
+ describe('buildStorageConfigFromIntegration', () => {
132
+ it('mapeia local com default de path', () => {
133
+ expect(buildStorageConfigFromIntegration('local', {})).toEqual({
134
+ 'storage-local-path': 'storage',
135
+ });
136
+ expect(
137
+ buildStorageConfigFromIntegration('local', { base_path: 'files' }),
138
+ ).toEqual({ 'storage-local-path': 'files' });
139
+ });
140
+
141
+ it('mapeia s3 com defaults', () => {
142
+ expect(buildStorageConfigFromIntegration('s3', {})).toEqual({
143
+ 'storage-s3-key': '',
144
+ 'storage-s3-secret': '',
145
+ 'storage-s3-region': 'us-east-1',
146
+ 'storage-s3-bucket': '',
147
+ });
148
+ });
149
+
150
+ it('mapeia gcs', () => {
151
+ expect(
152
+ buildStorageConfigFromIntegration('gcs', {
153
+ key_file_json: '{}',
154
+ bucket: 'b',
155
+ }),
156
+ ).toEqual({
157
+ 'storage-gcs-keyfile': '{}',
158
+ 'storage-gcs-bucket': 'b',
159
+ });
160
+ });
161
+
162
+ it('mapeia azure-blob', () => {
163
+ expect(
164
+ buildStorageConfigFromIntegration('azure-blob', {
165
+ account: 'acc',
166
+ key: 'k',
167
+ container: 'c',
168
+ }),
169
+ ).toEqual({
170
+ 'storage-abs-account': 'acc',
171
+ 'storage-abs-key': 'k',
172
+ 'storage-abs-container': 'c',
173
+ });
174
+ });
175
+
176
+ it('mapeia s3-compatible', () => {
177
+ expect(
178
+ buildStorageConfigFromIntegration('s3-compatible', {
179
+ access_key_id: 'ak',
180
+ secret_access_key: 'sk',
181
+ region: 'nyc3',
182
+ bucket: 'b',
183
+ endpoint: 'cdn.example.com',
184
+ }),
185
+ ).toEqual({
186
+ 'storage-spaces-key': 'ak',
187
+ 'storage-spaces-secret': 'sk',
188
+ 'storage-spaces-region': 'nyc3',
189
+ 'storage-spaces-bucket': 'b',
190
+ 'storage-spaces-cdn': 'cdn.example.com',
191
+ });
192
+ });
193
+
194
+ it('lança para provider desconhecido', () => {
195
+ expect(() =>
196
+ buildStorageConfigFromIntegration('dropbox', {}),
197
+ ).toThrow(BadRequestException);
198
+ });
199
+ });
200
+
201
+ describe('resolveStorageEnumProvider', () => {
202
+ it('mapeia cada slug conhecido', () => {
203
+ expect(resolveStorageEnumProvider('local')).toBe(EnumProvider.LOCAL);
204
+ expect(resolveStorageEnumProvider('S3')).toBe(EnumProvider.S3);
205
+ expect(resolveStorageEnumProvider('gcs')).toBe(EnumProvider.GCS);
206
+ expect(resolveStorageEnumProvider('azure-blob')).toBe(EnumProvider.AZURE);
207
+ expect(resolveStorageEnumProvider('s3-compatible')).toBe(
208
+ EnumProvider.SPACES,
209
+ );
210
+ });
211
+
212
+ it('lança para slug desconhecido', () => {
213
+ expect(() => resolveStorageEnumProvider('foo')).toThrow(
214
+ BadRequestException,
215
+ );
216
+ });
217
+ });
218
+
219
+ describe('resolveFileProviderSlug', () => {
220
+ it('normaliza slugs especiais', () => {
221
+ expect(resolveFileProviderSlug('azure-blob')).toBe('abs');
222
+ expect(resolveFileProviderSlug('s3-compatible')).toBe('spaces');
223
+ });
224
+
225
+ it('faz lowercase para outros slugs', () => {
226
+ expect(resolveFileProviderSlug('S3')).toBe('s3');
227
+ expect(resolveFileProviderSlug('Local')).toBe('local');
228
+ });
229
+ });
230
+
231
+ describe('buildOAuthConfigFromIntegration', () => {
232
+ it('inclui tenantId quando fornecido', () => {
233
+ expect(
234
+ buildOAuthConfigFromIntegration('microsoft', {
235
+ client_id: 'cid',
236
+ client_secret: 'sec',
237
+ tenant_id: 'tid',
238
+ }),
239
+ ).toEqual({ clientId: 'cid', clientSecret: 'sec', tenantId: 'tid' });
240
+ });
241
+
242
+ it('omite tenantId quando ausente', () => {
243
+ expect(
244
+ buildOAuthConfigFromIntegration('google', {
245
+ client_id: 'cid',
246
+ client_secret: 'sec',
247
+ }),
248
+ ).toEqual({ clientId: 'cid', clientSecret: 'sec' });
249
+ });
250
+ });
251
+
252
+ describe('buildDigitalOceanConfigFromIntegration', () => {
253
+ it('mapeia digitalocean', () => {
254
+ expect(
255
+ buildDigitalOceanConfigFromIntegration('digitalocean', {
256
+ api_token: 't',
257
+ cluster_id: 'c',
258
+ region: 'nyc',
259
+ video_node_pool_id: 'p',
260
+ video_node_pool_name: 'pool',
261
+ }),
262
+ ).toEqual({
263
+ apiToken: 't',
264
+ clusterId: 'c',
265
+ region: 'nyc',
266
+ videoNodePoolId: 'p',
267
+ videoNodePoolName: 'pool',
268
+ });
269
+ });
270
+
271
+ it('lança para provider desconhecido', () => {
272
+ expect(() =>
273
+ buildDigitalOceanConfigFromIntegration('aws', {}),
274
+ ).toThrow(BadRequestException);
275
+ });
276
+ });
277
+
278
+ describe('buildKubernetesConfigFromIntegration', () => {
279
+ it('mapeia kubernetes com default de namespace', () => {
280
+ expect(
281
+ buildKubernetesConfigFromIntegration('kubernetes', {
282
+ api_server: 'https://k8s',
283
+ token: 'tok',
284
+ ca_cert: 'cert',
285
+ }),
286
+ ).toEqual({
287
+ apiServer: 'https://k8s',
288
+ token: 'tok',
289
+ caCert: 'cert',
290
+ namespace: 'hcode',
291
+ });
292
+ });
293
+
294
+ it('lança para provider desconhecido', () => {
295
+ expect(() =>
296
+ buildKubernetesConfigFromIntegration('nomad', {}),
297
+ ).toThrow(BadRequestException);
298
+ });
299
+ });
300
+
301
+ describe('buildAiConfigFromIntegration', () => {
302
+ it('mapeia openai com organization', () => {
303
+ expect(
304
+ buildAiConfigFromIntegration('openai', {
305
+ api_key: 'k',
306
+ organization: 'org',
307
+ }),
308
+ ).toEqual({ apiKey: 'k', organization: 'org' });
309
+ });
310
+
311
+ it('mapeia openai sem organization', () => {
312
+ expect(buildAiConfigFromIntegration('openai', { api_key: 'k' })).toEqual({
313
+ apiKey: 'k',
314
+ });
315
+ });
316
+
317
+ it('mapeia gemini e claude apenas com apiKey', () => {
318
+ expect(buildAiConfigFromIntegration('gemini', { api_key: 'g' })).toEqual({
319
+ apiKey: 'g',
320
+ });
321
+ expect(buildAiConfigFromIntegration('claude', { api_key: 'c' })).toEqual({
322
+ apiKey: 'c',
323
+ });
324
+ });
325
+
326
+ it('lança para provider desconhecido', () => {
327
+ expect(() => buildAiConfigFromIntegration('llama', {})).toThrow(
328
+ BadRequestException,
329
+ );
330
+ });
331
+ });
332
+ });
@@ -141,12 +141,25 @@ export function resolveFileProviderSlug(providerSlug: string): string {
141
141
  export function buildOAuthConfigFromIntegration(
142
142
  _providerSlug: string,
143
143
  config: unknown,
144
- ): { clientId: string; clientSecret: string; tenantId?: string } {
144
+ ): {
145
+ clientId: string;
146
+ clientSecret: string;
147
+ tenantId?: string;
148
+ /** Apple Developer Team ID (Sign in with Apple). */
149
+ teamId?: string;
150
+ /** Key ID of the .p8 private key registered for Sign in with Apple. */
151
+ keyId?: string;
152
+ /** EC private key (PEM, .p8 contents) used to sign Apple's client_secret JWT. */
153
+ privateKey?: string;
154
+ } {
145
155
  const cfg = (config as Cfg) ?? {};
146
156
  return {
147
157
  clientId: String(cfg.client_id ?? ''),
148
158
  clientSecret: String(cfg.client_secret ?? ''),
149
159
  ...(cfg.tenant_id ? { tenantId: String(cfg.tenant_id) } : {}),
160
+ ...(cfg.team_id ? { teamId: String(cfg.team_id) } : {}),
161
+ ...(cfg.key_id ? { keyId: String(cfg.key_id) } : {}),
162
+ ...(cfg.private_key ? { privateKey: String(cfg.private_key) } : {}),
150
163
  };
151
164
  }
152
165