@hed-hog/core 0.0.400 → 0.0.402
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/dist/access-log/access-log.interceptor.d.ts +10 -0
- package/dist/access-log/access-log.interceptor.d.ts.map +1 -1
- package/dist/access-log/access-log.interceptor.js +15 -1
- package/dist/access-log/access-log.interceptor.js.map +1 -1
- package/dist/ai/ai.controller.d.ts +2 -0
- package/dist/ai/ai.controller.d.ts.map +1 -1
- package/dist/ai/ai.service.d.ts +86 -2
- package/dist/ai/ai.service.d.ts.map +1 -1
- package/dist/ai/ai.service.js +304 -23
- package/dist/ai/ai.service.js.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +3 -0
- package/dist/core.module.js.map +1 -1
- package/dist/document-kit/document-kit.module.d.ts +3 -0
- package/dist/document-kit/document-kit.module.d.ts.map +1 -0
- package/dist/document-kit/document-kit.module.js +24 -0
- package/dist/document-kit/document-kit.module.js.map +1 -0
- package/dist/document-kit/document-kit.registry.d.ts +55 -0
- package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
- package/dist/document-kit/document-kit.registry.js +91 -0
- package/dist/document-kit/document-kit.registry.js.map +1 -0
- package/dist/document-kit/document-kit.service.d.ts +49 -0
- package/dist/document-kit/document-kit.service.d.ts.map +1 -0
- package/dist/document-kit/document-kit.service.js +135 -0
- package/dist/document-kit/document-kit.service.js.map +1 -0
- package/dist/file/file-upload-ticket.service.d.ts +1 -1
- package/dist/file/file.controller.d.ts +3 -3
- package/dist/file/file.service.d.ts +136 -13
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +297 -27
- package/dist/file/file.service.js.map +1 -1
- package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
- package/dist/file/mcp-file-source.schema.js +8 -5
- package/dist/file/mcp-file-source.schema.js.map +1 -1
- package/dist/file/provider/abstract.provider.d.ts +33 -0
- package/dist/file/provider/abstract.provider.d.ts.map +1 -1
- package/dist/file/provider/abstract.provider.js +9 -0
- package/dist/file/provider/abstract.provider.js.map +1 -1
- package/dist/file/provider/s3-multipart.util.d.ts +17 -0
- package/dist/file/provider/s3-multipart.util.d.ts.map +1 -0
- package/dist/file/provider/s3-multipart.util.js +88 -0
- package/dist/file/provider/s3-multipart.util.js.map +1 -0
- package/dist/file/provider/s3.provider.d.ts +9 -1
- package/dist/file/provider/s3.provider.d.ts.map +1 -1
- package/dist/file/provider/s3.provider.js +16 -0
- package/dist/file/provider/s3.provider.js.map +1 -1
- package/dist/file/provider/spaces.provider.d.ts +10 -1
- package/dist/file/provider/spaces.provider.d.ts.map +1 -1
- package/dist/file/provider/spaces.provider.js +17 -0
- package/dist/file/provider/spaces.provider.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/integration-profile/integration-profile.controller.d.ts +1 -0
- package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +26 -0
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/integration-profile.service.d.ts +1 -0
- package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.service.js +3 -3
- package/dist/integration-profile/integration-profile.service.js.map +1 -1
- package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
- package/dist/integration-profile/testers/ai.testers.js +46 -8
- package/dist/integration-profile/testers/ai.testers.js.map +1 -1
- package/dist/integration-profile/testers/http-probe.d.ts.map +1 -1
- package/dist/integration-profile/testers/http-probe.js +5 -3
- package/dist/integration-profile/testers/http-probe.js.map +1 -1
- package/dist/integration-profile/testers/integration-tester.types.d.ts +5 -0
- package/dist/integration-profile/testers/integration-tester.types.d.ts.map +1 -1
- package/dist/mail/mail.service.d.ts.map +1 -1
- package/dist/mail/mail.service.js +16 -1
- package/dist/mail/mail.service.js.map +1 -1
- package/dist/mcp/mcp-server.service.d.ts.map +1 -1
- package/dist/mcp/mcp-server.service.js +10 -1
- package/dist/mcp/mcp-server.service.js.map +1 -1
- package/dist/mcp/mcp.module.d.ts.map +1 -1
- package/dist/mcp/mcp.module.js +2 -0
- package/dist/mcp/mcp.module.js.map +1 -1
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
- package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
- package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
- package/dist/meeting/meeting-provider.registry.d.ts +72 -0
- package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
- package/dist/meeting/meeting-provider.registry.js.map +1 -1
- package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
- package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
- package/dist/oauth/oauth-token-broker.service.js +105 -3
- package/dist/oauth/oauth-token-broker.service.js.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
- package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-bar.js +117 -0
- package/dist/pdf/document/charts/chart-bar.js.map +1 -0
- package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
- package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-donut.js +89 -0
- package/dist/pdf/document/charts/chart-donut.js.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.js +60 -0
- package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
- package/dist/pdf/document/charts/chart-line.d.ts +29 -0
- package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-line.js +89 -0
- package/dist/pdf/document/charts/chart-line.js.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.js +110 -0
- package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
- package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
- package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-radar.js +83 -0
- package/dist/pdf/document/charts/chart-radar.js.map +1 -0
- package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
- package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-scale.js +118 -0
- package/dist/pdf/document/charts/chart-scale.js.map +1 -0
- package/dist/pdf/document/charts/index.d.ts +8 -0
- package/dist/pdf/document/charts/index.d.ts.map +1 -0
- package/dist/pdf/document/charts/index.js +24 -0
- package/dist/pdf/document/charts/index.js.map +1 -0
- package/dist/pdf/document/index.d.ts +8 -0
- package/dist/pdf/document/index.d.ts.map +1 -0
- package/dist/pdf/document/index.js +24 -0
- package/dist/pdf/document/index.js.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.js +36 -0
- package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
- package/dist/pdf/document/pdf-document.css.d.ts +20 -0
- package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.css.js +327 -0
- package/dist/pdf/document/pdf-document.css.js.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.js +226 -0
- package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
- package/dist/pdf/document/pdf-document.service.d.ts +27 -0
- package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.service.js +81 -0
- package/dist/pdf/document/pdf-document.service.js.map +1 -0
- package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
- package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.theme.js +105 -0
- package/dist/pdf/document/pdf-document.theme.js.map +1 -0
- package/dist/pdf/document/pdf-document.types.d.ts +164 -0
- package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.types.js +18 -0
- package/dist/pdf/document/pdf-document.types.js.map +1 -0
- package/dist/pdf/pdf.module.d.ts.map +1 -1
- package/dist/pdf/pdf.module.js +3 -2
- package/dist/pdf/pdf.module.js.map +1 -1
- package/dist/pdf/pdf.service.d.ts +75 -1
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +167 -63
- package/dist/pdf/pdf.service.js.map +1 -1
- package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
- package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
- package/dist/pdf/templates/commercial-proposal.template.js +276 -0
- package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
- package/dist/setting/favicon.controller.d.ts +26 -0
- package/dist/setting/favicon.controller.d.ts.map +1 -0
- package/dist/setting/favicon.controller.js +130 -0
- package/dist/setting/favicon.controller.js.map +1 -0
- package/dist/setting/setting.module.d.ts.map +1 -1
- package/dist/setting/setting.module.js +2 -1
- package/dist/setting/setting.module.js.map +1 -1
- package/dist/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +3 -0
- package/dist/setting/setting.service.js.map +1 -1
- package/dist/task/task.service.d.ts +6 -0
- package/dist/task/task.service.d.ts.map +1 -1
- package/dist/task/task.service.js +44 -13
- package/dist/task/task.service.js.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts +35 -2
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.js +107 -22
- package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
- package/hedhog/data/integration_type.yaml +6 -0
- package/hedhog/data/route.yaml +8 -0
- package/hedhog/data/setting_group.yaml +52 -0
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.test.tsx.ejs +85 -2
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +576 -491
- package/hedhog/frontend/app/users/simulate-access-dialog.test.tsx.ejs +29 -50
- package/hedhog/frontend/app/users/simulate-access-dialog.tsx.ejs +146 -50
- package/hedhog/frontend/messages/en.json +81 -2
- package/hedhog/frontend/messages/pt.json +81 -2
- package/hedhog/table/file.yaml +3 -1
- package/package.json +5 -5
- package/src/access-log/access-log.interceptor.spec.ts +50 -0
- package/src/access-log/access-log.interceptor.ts +18 -0
- package/src/ai/ai-attachment.fragments.spec.ts +56 -3
- package/src/ai/ai.service.responses.spec.ts +429 -0
- package/src/ai/ai.service.ts +409 -27
- package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
- package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
- package/src/core.module.ts +3 -0
- package/src/document-kit/document-kit.module.ts +12 -0
- package/src/document-kit/document-kit.registry.ts +125 -0
- package/src/document-kit/document-kit.service.spec.ts +156 -0
- package/src/document-kit/document-kit.service.ts +173 -0
- package/src/file/file-direct-upload.spec.ts +344 -0
- package/src/file/file.service.spec.ts +168 -1
- package/src/file/file.service.ts +435 -36
- package/src/file/mcp-file-source.schema.ts +8 -5
- package/src/file/provider/abstract.provider.ts +57 -0
- package/src/file/provider/s3-multipart.spec.ts +123 -0
- package/src/file/provider/s3-multipart.util.ts +126 -0
- package/src/file/provider/s3.provider.ts +65 -1
- package/src/file/provider/spaces.provider.ts +67 -1
- package/src/index.ts +5 -0
- package/src/integration-profile/integration-profile.secrets.ts +26 -0
- package/src/integration-profile/integration-profile.service.ts +1 -0
- package/src/integration-profile/testers/ai.testers.ts +70 -5
- package/src/integration-profile/testers/http-probe.ts +5 -3
- package/src/integration-profile/testers/integration-tester.types.ts +5 -0
- package/src/integration-profile/testers/oauth.testers.spec.ts +13 -0
- package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
- package/src/integration-profile/testers/tester-coverage.spec.ts +12 -0
- package/src/mail/mail.service.spec.ts +25 -0
- package/src/mail/mail.service.ts +18 -1
- package/src/mcp/mcp-server.service.spec.ts +16 -0
- package/src/mcp/mcp-server.service.ts +10 -1
- package/src/mcp/mcp.module.ts +2 -0
- package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
- package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
- package/src/meeting/meeting-provider.registry.ts +80 -0
- package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
- package/src/oauth/oauth-token-broker.service.ts +129 -2
- package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
- package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
- package/src/pdf/document/charts/chart-bar.ts +217 -0
- package/src/pdf/document/charts/chart-donut.ts +163 -0
- package/src/pdf/document/charts/chart-gauge.ts +94 -0
- package/src/pdf/document/charts/chart-line.ts +159 -0
- package/src/pdf/document/charts/chart-primitives.ts +188 -0
- package/src/pdf/document/charts/chart-radar.ts +136 -0
- package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
- package/src/pdf/document/charts/chart-scale.ts +141 -0
- package/src/pdf/document/charts/charts.spec.ts +157 -0
- package/src/pdf/document/charts/index.ts +7 -0
- package/src/pdf/document/index.ts +7 -0
- package/src/pdf/document/pdf-document.chrome.ts +38 -0
- package/src/pdf/document/pdf-document.css.ts +326 -0
- package/src/pdf/document/pdf-document.renderer.spec.ts +172 -0
- package/src/pdf/document/pdf-document.renderer.ts +317 -0
- package/src/pdf/document/pdf-document.service.ts +77 -0
- package/src/pdf/document/pdf-document.theme.ts +146 -0
- package/src/pdf/document/pdf-document.types.ts +149 -0
- package/src/pdf/pdf.module.ts +3 -2
- package/src/pdf/pdf.service.spec.ts +131 -0
- package/src/pdf/pdf.service.ts +256 -82
- package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
- package/src/pdf/templates/commercial-proposal.template.ts +400 -0
- package/src/setting/favicon.controller.spec.ts +92 -0
- package/src/setting/favicon.controller.ts +135 -0
- package/src/setting/setting.module.ts +2 -1
- package/src/setting/setting.service.ts +3 -0
- package/src/task/task.service.spec.ts +77 -1
- package/src/task/task.service.ts +58 -14
- package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +263 -0
- package/src/whatsapp-sender/whatsapp-sender.service.ts +154 -16
|
@@ -26,6 +26,11 @@ export type IntegrationTestOutcome = {
|
|
|
26
26
|
* verificado, service account sem acesso ao app, pool inexistente). Não reprova.
|
|
27
27
|
*/
|
|
28
28
|
warning?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Códigos estáveis dos avisos, na ordem do `warning`, para a UI traduzir. O
|
|
31
|
+
* `warning` continua sendo o texto de fallback de quem não conhece o código.
|
|
32
|
+
*/
|
|
33
|
+
warningCodes?: string[];
|
|
29
34
|
};
|
|
30
35
|
|
|
31
36
|
export type IntegrationTestContext = {
|
|
@@ -126,6 +126,19 @@ describe('microsoft-oauth tester', () => {
|
|
|
126
126
|
expect(outcome.info).toContain('contoso');
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
it('aprova quando o token é maior que o limite de texto guardado', async () => {
|
|
130
|
+
fetchMock.mockResolvedValue(
|
|
131
|
+
jsonResponse({ token_type: 'Bearer', access_token: 'x'.repeat(5_000) }),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const outcome = await tester.test(
|
|
135
|
+
{ client_id: 'a', client_secret: 'b', tenant_id: 'contoso' },
|
|
136
|
+
ctx(),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(outcome.ok).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
129
142
|
it.each([
|
|
130
143
|
['AADSTS7000215', 'Invalid client secret.'],
|
|
131
144
|
['AADSTS700016', 'Application not found in tenant "contoso".'],
|
|
@@ -26,11 +26,14 @@ const all = [
|
|
|
26
26
|
const bySlug = (slug: string): IntegrationTester =>
|
|
27
27
|
all.find((tester) => tester.providerSlug === slug)!;
|
|
28
28
|
|
|
29
|
-
const ctx = (
|
|
29
|
+
const ctx = (
|
|
30
|
+
overrides: Partial<IntegrationTestContext> = {},
|
|
31
|
+
): IntegrationTestContext => ({
|
|
30
32
|
signal: AbortSignal.timeout(5_000),
|
|
31
33
|
typeSlug: 'test',
|
|
32
34
|
providerSlug: 'test',
|
|
33
35
|
secretValues: [],
|
|
36
|
+
...overrides,
|
|
34
37
|
});
|
|
35
38
|
|
|
36
39
|
function jsonResponse(body: unknown, status = 200) {
|
|
@@ -252,6 +255,67 @@ describe('openai tester', () => {
|
|
|
252
255
|
error: 'Invalid OpenAI API key.',
|
|
253
256
|
});
|
|
254
257
|
});
|
|
258
|
+
|
|
259
|
+
// O header de organização é opcional e só este testador o envia. Um `org-...`
|
|
260
|
+
// errado devolve 401, e a mensagem fixa de "chave inválida" mandava conferir
|
|
261
|
+
// justamente o campo que estava certo.
|
|
262
|
+
it('culpa a organização, e não a chave, quando é ela que a OpenAI recusa', async () => {
|
|
263
|
+
fetchMock.mockResolvedValue(
|
|
264
|
+
jsonResponse(
|
|
265
|
+
{
|
|
266
|
+
error: {
|
|
267
|
+
message: 'No such organization: org-errada.',
|
|
268
|
+
code: 'invalid_organization',
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
401,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const outcome = await tester.test(
|
|
276
|
+
{ api_key: 'sk-test', organization: 'org-errada' },
|
|
277
|
+
ctx(),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
expect(outcome.ok).toBe(false);
|
|
281
|
+
expect(outcome.error).toContain('organization');
|
|
282
|
+
expect(outcome.error).toContain('org-errada');
|
|
283
|
+
expect(outcome.error).not.toContain('Invalid OpenAI API key');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('acrescenta o motivo que a OpenAI deu quando a chave é mesmo o problema', async () => {
|
|
287
|
+
fetchMock.mockResolvedValue(
|
|
288
|
+
jsonResponse(
|
|
289
|
+
{ error: { message: 'Incorrect API key provided.', code: 'invalid_api_key' } },
|
|
290
|
+
401,
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const outcome = await tester.test({ api_key: 'sk-bad' }, ctx());
|
|
295
|
+
|
|
296
|
+
expect(outcome.error).toBe(
|
|
297
|
+
'Invalid OpenAI API key. Incorrect API key provided.',
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// O resultado de um tester não passa por `sanitizeTesterError`, então a
|
|
302
|
+
// redação do segredo tem de acontecer dentro do próprio testador.
|
|
303
|
+
it('não deixa a chave vazar pela mensagem do provider', async () => {
|
|
304
|
+
fetchMock.mockResolvedValue(
|
|
305
|
+
jsonResponse(
|
|
306
|
+
{ error: { message: 'Incorrect API key provided: sk-supersecreta.' } },
|
|
307
|
+
401,
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const outcome = await tester.test(
|
|
312
|
+
{ api_key: 'sk-supersecreta' },
|
|
313
|
+
ctx({ secretValues: ['sk-supersecreta'] }),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(outcome.error).not.toContain('sk-supersecreta');
|
|
317
|
+
expect(outcome.error).toContain('***');
|
|
318
|
+
});
|
|
255
319
|
});
|
|
256
320
|
|
|
257
321
|
describe('claude tester', () => {
|
|
@@ -15,6 +15,18 @@ const EXTERNAL_PROVIDERS = new Set([
|
|
|
15
15
|
// Abrir o certificado A1 é código do domínio fiscal: o tester se registra pelo
|
|
16
16
|
// NfseNationalModule (libraries/fiscal).
|
|
17
17
|
'nfse-national',
|
|
18
|
+
// App da Meta para redes sociais: o tester se registra pelo SocialModule
|
|
19
|
+
// (libraries/social), junto do client da Graph API.
|
|
20
|
+
'meta-social',
|
|
21
|
+
// App do Instagram (Instagram Login): o tester vive em libraries/social.
|
|
22
|
+
'instagram-social',
|
|
23
|
+
// Apps do Google do modulo social: os testers vivem em libraries/social.
|
|
24
|
+
'youtube-social',
|
|
25
|
+
'google-business-social',
|
|
26
|
+
// App do LinkedIn (paginas de empresa): o tester vive em libraries/social.
|
|
27
|
+
'linkedin-social',
|
|
28
|
+
// App do X (OAuth 2.0 com PKCE): o tester vive em libraries/social.
|
|
29
|
+
'x-social',
|
|
18
30
|
]);
|
|
19
31
|
|
|
20
32
|
/** Providers legítimos sem nenhum campo secreto. */
|
|
@@ -82,6 +82,31 @@ describe('MailService', () => {
|
|
|
82
82
|
expect(call('A\r\nB', {})).toBe('A<br/>B');
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
// Bug real: o aviso interno do SAC, escrito em HTML indentado, chegava com
|
|
86
|
+
// um vao entre cada campo — cada quebra entre dois <p> virava <br/>.
|
|
87
|
+
it('não gera <br/> na quebra que só separa uma tag da seguinte', () => {
|
|
88
|
+
expect(call('<div>\n <p>A</p>\n <p>B</p>\n</div>', {})).toBe(
|
|
89
|
+
'<div><p>A</p><p>B</p></div>',
|
|
90
|
+
);
|
|
91
|
+
expect(call('<p>A</p>\r\n\r\n<p>B</p>', {})).toBe('<p>A</p><p>B</p>');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('não gera <br/> entre tag e bloco {{#if}}/{{/if}}', () => {
|
|
95
|
+
const src =
|
|
96
|
+
'<p>A</p>\n{{#if x}}<p>X</p>{{/if}}\n<p>B</p>\n{{#if y}}\n <p>Y</p>\n{{else}}\n <p>N</p>\n{{/if}}';
|
|
97
|
+
expect(call(src, { x: true })).toBe('<p>A</p><p>X</p><p>B</p><p>N</p>');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('mantém o <br/> da quebra que vem depois de texto ou de variável', () => {
|
|
101
|
+
expect(call('Olá,\n<div>x</div>', {})).toBe('Olá,<br/><div>x</div>');
|
|
102
|
+
expect(call('Oi {{name}}\n<p>x</p>', { name: 'Bob' })).toBe(
|
|
103
|
+
'Oi Bob<br/><p>x</p>',
|
|
104
|
+
);
|
|
105
|
+
expect(call('<strong>A</strong>\ntexto', {})).toBe(
|
|
106
|
+
'<strong>A</strong><br/>texto',
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
85
110
|
it('preserva as quebras de linha de um documento HTML completo (não injeta <br/> dentro de <style>/<head>)', () => {
|
|
86
111
|
// Bug real: um HTML completo colado em Templates de e-mail (que
|
|
87
112
|
// funciona no preview cru de Campanhas) aparecia quebrado aqui porque
|
package/src/mail/mail.service.ts
CHANGED
|
@@ -44,6 +44,14 @@ import { IncidentService } from '../incident/incident.service';
|
|
|
44
44
|
|
|
45
45
|
export type { SendTemplatedMailResult } from './dto/send.dto';
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Quebra de linha (com a indentacao em volta) entre o fim de uma tag ou de um
|
|
49
|
+
* bloco do Handlebars e o comeco da proxima tag ou bloco. `{{variavel}}` nao
|
|
50
|
+
* conta como bloco: depois dela vem texto, e a quebra e do autor.
|
|
51
|
+
*/
|
|
52
|
+
const TAG_TO_TAG_LINE_BREAK =
|
|
53
|
+
/(>|\{\{[#/][^}]*\}\}|\{\{else\}\})[ \t]*(?:\r\n|\n|\r)\s*(?=<|\{\{[#/]|\{\{else\}\})/g;
|
|
54
|
+
|
|
47
55
|
@Injectable()
|
|
48
56
|
export class MailService implements OnModuleInit {
|
|
49
57
|
private readonly logger = new Logger(MailService.name);
|
|
@@ -692,9 +700,18 @@ export class MailService implements OnModuleInit {
|
|
|
692
700
|
// caso; aqui só espelhamos a mesma detecção pra não mutilar antes
|
|
693
701
|
// disso). Emails simples (sem <html>) continuam ganhando <br/> pra
|
|
694
702
|
// preservar quebras de linha digitadas pelo usuário.
|
|
703
|
+
//
|
|
704
|
+
// A quebra que só separa uma tag (ou um bloco `{{#if}}`/`{{/if}}`) da
|
|
705
|
+
// seguinte é indentação do template, não texto: virar <br/> ali punha
|
|
706
|
+
// uma linha em branco entre cada <p> de todo template escrito em HTML
|
|
707
|
+
// de várias linhas. Essa sai antes; a que vem depois de texto continua.
|
|
708
|
+
// A quebra final (todo bloco `|` do YAML termina em uma) também sai.
|
|
695
709
|
const normalized = isFullHtmlDocument(source)
|
|
696
710
|
? source
|
|
697
|
-
: source
|
|
711
|
+
: source
|
|
712
|
+
.trimEnd()
|
|
713
|
+
.replace(TAG_TO_TAG_LINE_BREAK, '$1')
|
|
714
|
+
.replace(/(\r\n|\n|\r)/g, '<br/>');
|
|
698
715
|
const template = Handlebars.compile(normalized, { noEscape: !escapeHtml });
|
|
699
716
|
return template(data);
|
|
700
717
|
} catch (error: any) {
|
|
@@ -195,6 +195,22 @@ describe('McpServerService', () => {
|
|
|
195
195
|
);
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
+
it('handler void responde { success: true } em vez de um text ausente', async () => {
|
|
199
|
+
const { service, deps } = makeService();
|
|
200
|
+
const handler = jest.fn<any>().mockResolvedValue(undefined);
|
|
201
|
+
deps.registry.getAll.mockReturnValue([]);
|
|
202
|
+
deps.registry.get.mockReturnValue({ definition: { name: 'tool-a' }, handler });
|
|
203
|
+
|
|
204
|
+
(service as any).buildServer(context);
|
|
205
|
+
const callHandler = getRegisteredHandler(CallToolRequestSchema);
|
|
206
|
+
|
|
207
|
+
const result = await callHandler({ params: { name: 'tool-a', arguments: {} } });
|
|
208
|
+
|
|
209
|
+
expect(result).toEqual({
|
|
210
|
+
content: [{ type: 'text', text: JSON.stringify({ success: true }) }],
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
198
214
|
it('trata argumentos ausentes como objeto vazio', async () => {
|
|
199
215
|
const { service, deps } = makeService();
|
|
200
216
|
const handler = jest.fn<any>().mockResolvedValue({});
|
|
@@ -88,7 +88,16 @@ export class McpServerService {
|
|
|
88
88
|
success: true,
|
|
89
89
|
durationMs: Date.now() - start,
|
|
90
90
|
});
|
|
91
|
-
|
|
91
|
+
// Handler `void` (ex.: um delete) dá `JSON.stringify(undefined) === undefined`, e o
|
|
92
|
+
// cliente MCP recusa o resultado inteiro por faltar `text` — mesmo com a ação feita.
|
|
93
|
+
return {
|
|
94
|
+
content: [
|
|
95
|
+
{
|
|
96
|
+
type: 'text',
|
|
97
|
+
text: result === undefined ? JSON.stringify({ success: true }) : JSON.stringify(result),
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
};
|
|
92
101
|
} catch (e: any) {
|
|
93
102
|
await this.audit.record({
|
|
94
103
|
userId: context.userId,
|
package/src/mcp/mcp.module.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { McpPermissionService } from './mcp-permission.service';
|
|
|
32
32
|
import { McpRegistryService } from './mcp-registry.service';
|
|
33
33
|
import { McpServerService } from './mcp-server.service';
|
|
34
34
|
import { McpController } from './mcp.controller';
|
|
35
|
+
import { CoreAccessLogMcpTools } from './tools/core-access-log.mcp-tools';
|
|
35
36
|
import { CoreAiMcpTools } from './tools/core-ai.mcp-tools';
|
|
36
37
|
import { CoreDashboardItemsMcpTools } from './tools/core-dashboard-items.mcp-tools';
|
|
37
38
|
import { CoreDashboardMcpTools } from './tools/core-dashboard.mcp-tools';
|
|
@@ -111,6 +112,7 @@ import { AccessLogModule } from '../access-log/access-log.module';
|
|
|
111
112
|
CoreAiMcpTools,
|
|
112
113
|
CoreMcpChatMcpTools,
|
|
113
114
|
CoreSystemMcpTools,
|
|
115
|
+
CoreAccessLogMcpTools,
|
|
114
116
|
],
|
|
115
117
|
exports: [McpRegistryService, McpServerService, McpApiKeyService, McpPermissionService],
|
|
116
118
|
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { AccessLogService } from '../../access-log/access-log.service';
|
|
3
|
+
import { McpTool } from '../decorators/mcp-tool.decorator';
|
|
4
|
+
import { McpContext } from '../types/mcp-context.type';
|
|
5
|
+
|
|
6
|
+
const TYPES = ['http', 'mcp', 'all'] as const;
|
|
7
|
+
const SIMULATED = ['all', 'only', 'exclude'] as const;
|
|
8
|
+
|
|
9
|
+
@Injectable()
|
|
10
|
+
export class CoreAccessLogMcpTools {
|
|
11
|
+
constructor(private readonly accessLogService: AccessLogService) {}
|
|
12
|
+
|
|
13
|
+
@McpTool({
|
|
14
|
+
name: 'core.access-log.list',
|
|
15
|
+
description:
|
|
16
|
+
'Lists the access log (the admin "Access log" screen): every authenticated HTTP request and MCP tool call, with user, method/URL or tool, IP, user agent and whether it happened during an access simulation. Newest first.',
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
page: { type: 'number', description: 'Page number (default: 1)' },
|
|
21
|
+
pageSize: { type: 'number', description: 'Items per page (default: 20)' },
|
|
22
|
+
userId: { type: 'number', description: 'Filter by user id' },
|
|
23
|
+
userSearch: { type: 'string', description: 'Matches the user name' },
|
|
24
|
+
createdAtFrom: { type: 'string', description: 'Start date (YYYY-MM-DD)' },
|
|
25
|
+
createdAtTo: { type: 'string', description: 'End date (YYYY-MM-DD), inclusive' },
|
|
26
|
+
type: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'http = REST requests, mcp = MCP tool calls (default: all)',
|
|
29
|
+
enum: [...TYPES],
|
|
30
|
+
},
|
|
31
|
+
simulated: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'only/exclude accesses made while an admin simulated another user (default: all)',
|
|
34
|
+
enum: [...SIMULATED],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
readOnly: true,
|
|
39
|
+
})
|
|
40
|
+
async list(
|
|
41
|
+
args: {
|
|
42
|
+
page?: number;
|
|
43
|
+
pageSize?: number;
|
|
44
|
+
userId?: number;
|
|
45
|
+
userSearch?: string;
|
|
46
|
+
createdAtFrom?: string;
|
|
47
|
+
createdAtTo?: string;
|
|
48
|
+
type?: string;
|
|
49
|
+
simulated?: string;
|
|
50
|
+
},
|
|
51
|
+
_context: McpContext,
|
|
52
|
+
): Promise<any> {
|
|
53
|
+
return this.accessLogService.list({
|
|
54
|
+
page: args.page ?? 1,
|
|
55
|
+
pageSize: args.pageSize ?? 20,
|
|
56
|
+
userId: args.userId,
|
|
57
|
+
userSearch: args.userSearch,
|
|
58
|
+
createdAtFrom: args.createdAtFrom,
|
|
59
|
+
createdAtTo: args.createdAtTo,
|
|
60
|
+
type: TYPES.includes(args.type as any) ? (args.type as (typeof TYPES)[number]) : undefined,
|
|
61
|
+
simulated: SIMULATED.includes(args.simulated as any)
|
|
62
|
+
? (args.simulated as (typeof SIMULATED)[number])
|
|
63
|
+
: undefined,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -130,7 +130,7 @@ export class CoreFilesMcpTools {
|
|
|
130
130
|
@McpTool({
|
|
131
131
|
name: 'core.files.create-upload-url',
|
|
132
132
|
description:
|
|
133
|
-
'Opens an upload and returns a short-lived, single-use URL
|
|
133
|
+
'Opens an upload and returns a short-lived, single-use URL on the Hub API host to PUT the file to: PUT the binary to the returned url, then call core.files.upload.complete to get the fileId (or pass the uploadId straight to the tool that needs the file). Fastest path, but the url must be reachable from wherever the PUT runs. Clients in a network-restricted sandbox (e.g. claude.ai) usually cannot reach it; if you are in one, or the PUT fails with host_not_allowed, 403 or a network error, use core.files.upload.init instead, which sends the file through MCP calls. Works for files up to the storage-max-size setting (10mb by default).',
|
|
134
134
|
inputSchema: {
|
|
135
135
|
type: 'object',
|
|
136
136
|
properties: {
|
|
@@ -185,14 +185,14 @@ export class CoreFilesMcpTools {
|
|
|
185
185
|
headers: { 'Content-Type': ticket.mimetype },
|
|
186
186
|
expiresAt: ticket.expires_at.toISOString(),
|
|
187
187
|
sizeBytes: ticket.size_bytes,
|
|
188
|
-
next: 'PUT the raw file bytes to url, then call core.files.upload.complete with this uploadId.',
|
|
188
|
+
next: 'PUT the raw file bytes to url, then call core.files.upload.complete with this uploadId. If the PUT is blocked (host_not_allowed, 403, network error), open a new upload with core.files.upload.init and send the file in chunks instead.',
|
|
189
189
|
};
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
@McpTool({
|
|
193
193
|
name: 'core.files.upload.init',
|
|
194
194
|
description:
|
|
195
|
-
'Opens an upload
|
|
195
|
+
'Opens an upload sent in base64 chunks through MCP calls, with no extra network access. Use it when core.files.create-upload-url is not an option: a network-restricted sandbox (e.g. claude.ai), or a PUT that failed with host_not_allowed, 403 or a network error. Works for files up to the storage-max-size setting (10mb by default), well beyond the ~256kb limit of inline fileBase64. Returns uploadId, chunkBytes and totalChunks. Then call core.files.upload.append once per chunk (index 0..totalChunks-1, each chunk at most chunkBytes raw bytes before base64 encoding) and finish with core.files.upload.complete to get the fileId, or pass the uploadId to the tool that needs the file. Generate the chunks programmatically in your sandbox (split the file and print the base64 of each chunk) rather than typing base64 by hand. The upload expires at expiresAt.',
|
|
196
196
|
inputSchema: {
|
|
197
197
|
type: 'object',
|
|
198
198
|
properties: {
|
|
@@ -225,7 +225,7 @@ export class CoreFilesMcpTools {
|
|
|
225
225
|
chunkBytes,
|
|
226
226
|
totalChunks: Math.ceil(ticket.size_bytes / chunkBytes),
|
|
227
227
|
expiresAt: ticket.expires_at.toISOString(),
|
|
228
|
-
next: `Send each chunk with core.files.upload.append, indexes 0
|
|
228
|
+
next: `Send each chunk with core.files.upload.append, indexes 0..${Math.ceil(ticket.size_bytes / chunkBytes) - 1}, at most ${chunkBytes} raw bytes per chunk before base64 encoding, then call core.files.upload.complete with this uploadId.`,
|
|
229
229
|
};
|
|
230
230
|
}
|
|
231
231
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Injectable, Logger } from '@nestjs/common';
|
|
2
|
+
import { MeetingChatMessage } from './meeting.constants';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Pedido de criação de uma reunião online para uma entidade de negócio qualquer.
|
|
@@ -218,6 +219,61 @@ export interface MeetingChatAttachmentContent {
|
|
|
218
219
|
size: number;
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Pedido de publicação de uma mensagem no chat da reunião de uma origem.
|
|
224
|
+
*
|
|
225
|
+
* O corpo é HTML simples — `<p>`, `<br>`, `<strong>`, `<a href>` — e não texto
|
|
226
|
+
* puro porque o que motiva este contrato é mandar um link clicável para dentro
|
|
227
|
+
* da reunião. Provedor que não renderize HTML degrada tirando as tags; o texto
|
|
228
|
+
* continua legível.
|
|
229
|
+
*/
|
|
230
|
+
export interface PostMeetingChatMessageInput {
|
|
231
|
+
sourceModule: string;
|
|
232
|
+
sourceEntity: string;
|
|
233
|
+
sourceEntityId: string;
|
|
234
|
+
html: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Desfecho da publicação, sem exceção.
|
|
239
|
+
*
|
|
240
|
+
* Postar no chat é conveniência, não o caminho crítico: quem chama tem sempre o
|
|
241
|
+
* plano B de mostrar o texto para alguém colar à mão. Transformar "o tenant
|
|
242
|
+
* desligou o chat da reunião" em exceção obrigaria toda tela a tratar erro para
|
|
243
|
+
* uma situação que é normal.
|
|
244
|
+
*/
|
|
245
|
+
export type PostMeetingChatFailure =
|
|
246
|
+
/** Não há identidade/configuração para escrever. */
|
|
247
|
+
| 'unavailable'
|
|
248
|
+
/** A reunião não tem chat (ninguém entrou, ou o tenant o desligou). */
|
|
249
|
+
| 'no_chat'
|
|
250
|
+
/** Falta consentimento do escopo de envio na conta que escreve. */
|
|
251
|
+
| 'missing_scope'
|
|
252
|
+
/** O provedor recusou por outro motivo. */
|
|
253
|
+
| 'failed';
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Um objeto com campos opcionais, e não uma união discriminada, porque o
|
|
257
|
+
* monorepo compila com `strictNullChecks: false` — e sem essa flag o
|
|
258
|
+
* TypeScript não estreita a união pelo `ok`, obrigando todo consumidor a um
|
|
259
|
+
* cast. Melhor um contrato que funciona do que um mais elegante que ninguém
|
|
260
|
+
* consegue usar sem contorno.
|
|
261
|
+
*
|
|
262
|
+
* `ok: true` traz `providerMessageId`; `ok: false` traz `reason` e `message`.
|
|
263
|
+
*/
|
|
264
|
+
export interface PostMeetingChatMessageResult {
|
|
265
|
+
ok: boolean;
|
|
266
|
+
providerMessageId?: string | null;
|
|
267
|
+
reason?: PostMeetingChatFailure;
|
|
268
|
+
message?: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface ListMeetingChatMessagesInput {
|
|
272
|
+
sourceModule: string;
|
|
273
|
+
sourceEntity: string;
|
|
274
|
+
sourceEntityId: string;
|
|
275
|
+
}
|
|
276
|
+
|
|
221
277
|
/**
|
|
222
278
|
* Provedor de reuniões online (Teams hoje; Meet/Zoom cabem no mesmo contrato).
|
|
223
279
|
*
|
|
@@ -269,6 +325,30 @@ export interface IMeetingProvider {
|
|
|
269
325
|
fetchChatAttachment?(
|
|
270
326
|
input: FetchMeetingChatAttachmentInput,
|
|
271
327
|
): Promise<MeetingChatAttachmentContent | null>;
|
|
328
|
+
/**
|
|
329
|
+
* Publica uma mensagem no chat da reunião.
|
|
330
|
+
*
|
|
331
|
+
* Opcional pelo mesmo motivo de {@link fetchChatAttachment}, e nunca lança:
|
|
332
|
+
* o desfecho vem no retorno.
|
|
333
|
+
*/
|
|
334
|
+
postChatMessage?(
|
|
335
|
+
input: PostMeetingChatMessageInput,
|
|
336
|
+
): Promise<PostMeetingChatMessageResult>;
|
|
337
|
+
/**
|
|
338
|
+
* As mensagens do chat que o provedor já capturou para esta origem.
|
|
339
|
+
*
|
|
340
|
+
* Existe porque o evento `MEETING_CHAT_AVAILABLE_EVENT` tem teto de mensagens
|
|
341
|
+
* e, quando estoura, entrega as mais RECENTES. Isso serve para achar o
|
|
342
|
+
* material que o instrutor colou no fim da aula, e não serve para uma turma
|
|
343
|
+
* de 250 pessoas em que cada uma escreve: as primeiras somem do payload.
|
|
344
|
+
*
|
|
345
|
+
* Lê o que o provedor já guardou, sem ida nova ao serviço remoto — é a
|
|
346
|
+
* alternativa a fazer o módulo de origem ler a tabela do provedor, que é
|
|
347
|
+
* exatamente o acoplamento que este contrato existe para evitar.
|
|
348
|
+
*/
|
|
349
|
+
listChatMessages?(
|
|
350
|
+
input: ListMeetingChatMessagesInput,
|
|
351
|
+
): Promise<MeetingChatMessage[]>;
|
|
272
352
|
}
|
|
273
353
|
|
|
274
354
|
/**
|
|
@@ -70,6 +70,32 @@ describe('OAuthTokenBrokerService', () => {
|
|
|
70
70
|
token_expires_at: new Date('2020-01-01T00:00:00Z'),
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* O Prisma 6 devolve coluna `Bytes` como `Uint8Array`, não como `Buffer`. Os
|
|
75
|
+
* mocks daqui usavam `Buffer`, então os testes passavam enquanto produção
|
|
76
|
+
* falhava em TODA conta: `Uint8Array.toString()` imprime os bytes em decimal
|
|
77
|
+
* ("115,97,...") em vez de decodificar utf8, o decrypt não achava as 4 partes
|
|
78
|
+
* e o usuário via "reconecte a conta" — que reconectar não resolvia.
|
|
79
|
+
*/
|
|
80
|
+
it('decrypts a Bytes column that chega como Uint8Array (Prisma 6)', async () => {
|
|
81
|
+
const encrypted = deps.security.encrypt('refresh-original');
|
|
82
|
+
deps.prisma.user_account.findFirst.mockResolvedValue({
|
|
83
|
+
id: 42,
|
|
84
|
+
refresh_token: new Uint8Array(Buffer.from(encrypted)),
|
|
85
|
+
scopes: 'openid,offline_access',
|
|
86
|
+
token_expires_at: new Date('2020-01-01T00:00:00Z'),
|
|
87
|
+
} as any);
|
|
88
|
+
deps.http.post.mockReturnValue(
|
|
89
|
+
of({ data: { access_token: 'ACCESS-1', expires_in: 3600 } }) as any,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const result = await service.getAccessToken(1, PROVIDER);
|
|
93
|
+
|
|
94
|
+
expect(result.accessToken).toBe('ACCESS-1');
|
|
95
|
+
const [, body] = deps.http.post.mock.calls[0] as [string, string, unknown];
|
|
96
|
+
expect(body).toContain('refresh_token=refresh-original');
|
|
97
|
+
});
|
|
98
|
+
|
|
73
99
|
it('decrypts the stored refresh token, exchanges it, and returns an access token', async () => {
|
|
74
100
|
deps.prisma.user_account.findFirst.mockResolvedValue(
|
|
75
101
|
storedRefreshTokenRow(deps.security, 'refresh-original') as any,
|
|
@@ -161,6 +187,88 @@ describe('OAuthTokenBrokerService', () => {
|
|
|
161
187
|
});
|
|
162
188
|
});
|
|
163
189
|
|
|
190
|
+
/**
|
|
191
|
+
* A Microsoft rotaciona o refresh token a cada troca e invalida o anterior.
|
|
192
|
+
* Com várias réplicas disparando o mesmo tick de cron, a segunda a chegar
|
|
193
|
+
* recebe `invalid_grant` por uma corrida, não por revogação — e reportar
|
|
194
|
+
* "reconecte a conta" ali é enganoso: reconectar não resolve, a corrida se
|
|
195
|
+
* repete no ciclo seguinte.
|
|
196
|
+
*/
|
|
197
|
+
describe('rotação concorrente', () => {
|
|
198
|
+
it('refaz a troca com o token que outro processo gravou', async () => {
|
|
199
|
+
deps.prisma.user_account.findFirst
|
|
200
|
+
// leitura inicial: o token que vai falhar
|
|
201
|
+
.mockResolvedValueOnce(
|
|
202
|
+
storedRefreshTokenRow(deps.security, 'refresh-antigo') as any,
|
|
203
|
+
)
|
|
204
|
+
// releitura após o invalid_grant: outro processo já rotacionou
|
|
205
|
+
.mockResolvedValueOnce(
|
|
206
|
+
storedRefreshTokenRow(deps.security, 'refresh-novo') as any,
|
|
207
|
+
)
|
|
208
|
+
// a retentativa carrega a conta de novo
|
|
209
|
+
.mockResolvedValueOnce(
|
|
210
|
+
storedRefreshTokenRow(deps.security, 'refresh-novo') as any,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
deps.http.post
|
|
214
|
+
.mockReturnValueOnce(
|
|
215
|
+
throwError(() => ({
|
|
216
|
+
response: { data: { error: 'invalid_grant' }, status: 400 },
|
|
217
|
+
})) as any,
|
|
218
|
+
)
|
|
219
|
+
.mockReturnValueOnce(
|
|
220
|
+
of({ data: { access_token: 'token-bom', expires_in: 3600 } }) as any,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const result = await service.getAccessToken(1, PROVIDER);
|
|
224
|
+
|
|
225
|
+
expect(result.accessToken).toBe('token-bom');
|
|
226
|
+
expect(deps.http.post).toHaveBeenCalledTimes(2);
|
|
227
|
+
// A segunda troca tem que usar o token NOVO, não repetir o que falhou.
|
|
228
|
+
const segundaChamada = String((deps.http.post.mock.calls[1] as any[])[1]);
|
|
229
|
+
expect(segundaChamada).toContain('refresh-novo');
|
|
230
|
+
expect(segundaChamada).not.toContain('refresh-antigo');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('ainda reporta revogação quando o token no banco não mudou', async () => {
|
|
234
|
+
// Sem essa distinção, uma conta de fato desconectada entraria em laço.
|
|
235
|
+
deps.prisma.user_account.findFirst.mockResolvedValue(
|
|
236
|
+
storedRefreshTokenRow(deps.security, 'refresh-original') as any,
|
|
237
|
+
);
|
|
238
|
+
deps.http.post.mockReturnValue(
|
|
239
|
+
throwError(() => ({
|
|
240
|
+
response: { data: { error: 'invalid_grant' }, status: 400 },
|
|
241
|
+
})) as any,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
await expect(service.getAccessToken(1, PROVIDER)).rejects.toMatchObject({
|
|
245
|
+
reason: 'refresh_revoked',
|
|
246
|
+
});
|
|
247
|
+
// Uma tentativa só: não adianta insistir contra revogação real.
|
|
248
|
+
expect(deps.http.post).toHaveBeenCalledTimes(1);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('não persegue rotações indefinidamente', async () => {
|
|
252
|
+
// Cada releitura devolve um token diferente, simulando réplicas
|
|
253
|
+
// rotacionando sem parar. O teto tem que encerrar.
|
|
254
|
+
let n = 0;
|
|
255
|
+
deps.prisma.user_account.findFirst.mockImplementation(
|
|
256
|
+
async () =>
|
|
257
|
+
storedRefreshTokenRow(deps.security, `refresh-${n++}`) as any,
|
|
258
|
+
);
|
|
259
|
+
deps.http.post.mockReturnValue(
|
|
260
|
+
throwError(() => ({
|
|
261
|
+
response: { data: { error: 'invalid_grant' }, status: 400 },
|
|
262
|
+
})) as any,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
await expect(service.getAccessToken(1, PROVIDER)).rejects.toMatchObject({
|
|
266
|
+
reason: 'refresh_revoked',
|
|
267
|
+
});
|
|
268
|
+
expect(deps.http.post.mock.calls.length).toBeLessThanOrEqual(3);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
164
272
|
it('parses granted scopes from the stored account', async () => {
|
|
165
273
|
deps.prisma.user_account.findFirst.mockResolvedValue({
|
|
166
274
|
scopes: 'openid, Calendars.Read offline_access',
|