@teneo-protocol/sdk 1.0.0
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/.dockerignore +14 -0
- package/.env.test.example +14 -0
- package/.eslintrc.json +26 -0
- package/.github/workflows/claude-code-review.yml +78 -0
- package/.github/workflows/claude-reviewer.yml +64 -0
- package/.github/workflows/publish-npm.yml +38 -0
- package/.github/workflows/push-to-main.yml +23 -0
- package/.node-version +1 -0
- package/.prettierrc +11 -0
- package/Dockerfile +25 -0
- package/LICENCE +661 -0
- package/README.md +709 -0
- package/dist/constants.d.ts +42 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +45 -0
- package/dist/constants.js.map +1 -0
- package/dist/core/websocket-client.d.ts +261 -0
- package/dist/core/websocket-client.d.ts.map +1 -0
- package/dist/core/websocket-client.js +875 -0
- package/dist/core/websocket-client.js.map +1 -0
- package/dist/formatters/response-formatter.d.ts +354 -0
- package/dist/formatters/response-formatter.d.ts.map +1 -0
- package/dist/formatters/response-formatter.js +575 -0
- package/dist/formatters/response-formatter.js.map +1 -0
- package/dist/handlers/message-handler-registry.d.ts +155 -0
- package/dist/handlers/message-handler-registry.d.ts.map +1 -0
- package/dist/handlers/message-handler-registry.js +216 -0
- package/dist/handlers/message-handler-registry.js.map +1 -0
- package/dist/handlers/message-handlers/agent-selected-handler.d.ts +112 -0
- package/dist/handlers/message-handlers/agent-selected-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/agent-selected-handler.js +40 -0
- package/dist/handlers/message-handlers/agent-selected-handler.js.map +1 -0
- package/dist/handlers/message-handlers/agents-list-handler.d.ts +14 -0
- package/dist/handlers/message-handlers/agents-list-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/agents-list-handler.js +25 -0
- package/dist/handlers/message-handlers/agents-list-handler.js.map +1 -0
- package/dist/handlers/message-handlers/auth-error-handler.d.ts +71 -0
- package/dist/handlers/message-handlers/auth-error-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/auth-error-handler.js +30 -0
- package/dist/handlers/message-handlers/auth-error-handler.js.map +1 -0
- package/dist/handlers/message-handlers/auth-message-handler.d.ts +18 -0
- package/dist/handlers/message-handlers/auth-message-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/auth-message-handler.js +60 -0
- package/dist/handlers/message-handlers/auth-message-handler.js.map +1 -0
- package/dist/handlers/message-handlers/auth-required-handler.d.ts +76 -0
- package/dist/handlers/message-handlers/auth-required-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/auth-required-handler.js +23 -0
- package/dist/handlers/message-handlers/auth-required-handler.js.map +1 -0
- package/dist/handlers/message-handlers/auth-success-handler.d.ts +18 -0
- package/dist/handlers/message-handlers/auth-success-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/auth-success-handler.js +51 -0
- package/dist/handlers/message-handlers/auth-success-handler.js.map +1 -0
- package/dist/handlers/message-handlers/base-handler.d.ts +55 -0
- package/dist/handlers/message-handlers/base-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/base-handler.js +83 -0
- package/dist/handlers/message-handlers/base-handler.js.map +1 -0
- package/dist/handlers/message-handlers/challenge-handler.d.ts +73 -0
- package/dist/handlers/message-handlers/challenge-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/challenge-handler.js +47 -0
- package/dist/handlers/message-handlers/challenge-handler.js.map +1 -0
- package/dist/handlers/message-handlers/error-message-handler.d.ts +76 -0
- package/dist/handlers/message-handlers/error-message-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/error-message-handler.js +29 -0
- package/dist/handlers/message-handlers/error-message-handler.js.map +1 -0
- package/dist/handlers/message-handlers/index.d.ts +28 -0
- package/dist/handlers/message-handlers/index.d.ts.map +1 -0
- package/dist/handlers/message-handlers/index.js +100 -0
- package/dist/handlers/message-handlers/index.js.map +1 -0
- package/dist/handlers/message-handlers/list-rooms-response-handler.d.ts +122 -0
- package/dist/handlers/message-handlers/list-rooms-response-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/list-rooms-response-handler.js +30 -0
- package/dist/handlers/message-handlers/list-rooms-response-handler.js.map +1 -0
- package/dist/handlers/message-handlers/ping-pong-handler.d.ts +104 -0
- package/dist/handlers/message-handlers/ping-pong-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/ping-pong-handler.js +36 -0
- package/dist/handlers/message-handlers/ping-pong-handler.js.map +1 -0
- package/dist/handlers/message-handlers/regular-message-handler.d.ts +56 -0
- package/dist/handlers/message-handlers/regular-message-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/regular-message-handler.js +59 -0
- package/dist/handlers/message-handlers/regular-message-handler.js.map +1 -0
- package/dist/handlers/message-handlers/subscribe-response-handler.d.ts +81 -0
- package/dist/handlers/message-handlers/subscribe-response-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/subscribe-response-handler.js +48 -0
- package/dist/handlers/message-handlers/subscribe-response-handler.js.map +1 -0
- package/dist/handlers/message-handlers/task-response-handler.d.ts +14 -0
- package/dist/handlers/message-handlers/task-response-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/task-response-handler.js +44 -0
- package/dist/handlers/message-handlers/task-response-handler.js.map +1 -0
- package/dist/handlers/message-handlers/types.d.ts +51 -0
- package/dist/handlers/message-handlers/types.d.ts.map +1 -0
- package/dist/handlers/message-handlers/types.js +7 -0
- package/dist/handlers/message-handlers/types.js.map +1 -0
- package/dist/handlers/message-handlers/unsubscribe-response-handler.d.ts +81 -0
- package/dist/handlers/message-handlers/unsubscribe-response-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/unsubscribe-response-handler.js +48 -0
- package/dist/handlers/message-handlers/unsubscribe-response-handler.js.map +1 -0
- package/dist/handlers/webhook-handler.d.ts +202 -0
- package/dist/handlers/webhook-handler.d.ts.map +1 -0
- package/dist/handlers/webhook-handler.js +511 -0
- package/dist/handlers/webhook-handler.js.map +1 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +217 -0
- package/dist/index.js.map +1 -0
- package/dist/managers/agent-registry.d.ts +173 -0
- package/dist/managers/agent-registry.d.ts.map +1 -0
- package/dist/managers/agent-registry.js +310 -0
- package/dist/managers/agent-registry.js.map +1 -0
- package/dist/managers/connection-manager.d.ts +134 -0
- package/dist/managers/connection-manager.d.ts.map +1 -0
- package/dist/managers/connection-manager.js +176 -0
- package/dist/managers/connection-manager.js.map +1 -0
- package/dist/managers/index.d.ts +9 -0
- package/dist/managers/index.d.ts.map +1 -0
- package/dist/managers/index.js +16 -0
- package/dist/managers/index.js.map +1 -0
- package/dist/managers/message-router.d.ts +112 -0
- package/dist/managers/message-router.d.ts.map +1 -0
- package/dist/managers/message-router.js +260 -0
- package/dist/managers/message-router.js.map +1 -0
- package/dist/managers/room-manager.d.ts +165 -0
- package/dist/managers/room-manager.d.ts.map +1 -0
- package/dist/managers/room-manager.js +227 -0
- package/dist/managers/room-manager.js.map +1 -0
- package/dist/teneo-sdk.d.ts +703 -0
- package/dist/teneo-sdk.d.ts.map +1 -0
- package/dist/teneo-sdk.js +907 -0
- package/dist/teneo-sdk.js.map +1 -0
- package/dist/types/config.d.ts +1047 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +720 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/error-codes.d.ts +29 -0
- package/dist/types/error-codes.d.ts.map +1 -0
- package/dist/types/error-codes.js +41 -0
- package/dist/types/error-codes.js.map +1 -0
- package/dist/types/events.d.ts +616 -0
- package/dist/types/events.d.ts.map +1 -0
- package/dist/types/events.js +261 -0
- package/dist/types/events.js.map +1 -0
- package/dist/types/health.d.ts +40 -0
- package/dist/types/health.d.ts.map +1 -0
- package/dist/types/health.js +6 -0
- package/dist/types/health.js.map +1 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +123 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/messages.d.ts +3734 -0
- package/dist/types/messages.d.ts.map +1 -0
- package/dist/types/messages.js +482 -0
- package/dist/types/messages.js.map +1 -0
- package/dist/types/validation.d.ts +81 -0
- package/dist/types/validation.d.ts.map +1 -0
- package/dist/types/validation.js +115 -0
- package/dist/types/validation.js.map +1 -0
- package/dist/utils/bounded-queue.d.ts +127 -0
- package/dist/utils/bounded-queue.d.ts.map +1 -0
- package/dist/utils/bounded-queue.js +181 -0
- package/dist/utils/bounded-queue.js.map +1 -0
- package/dist/utils/circuit-breaker.d.ts +141 -0
- package/dist/utils/circuit-breaker.d.ts.map +1 -0
- package/dist/utils/circuit-breaker.js +215 -0
- package/dist/utils/circuit-breaker.js.map +1 -0
- package/dist/utils/deduplication-cache.d.ts +110 -0
- package/dist/utils/deduplication-cache.d.ts.map +1 -0
- package/dist/utils/deduplication-cache.js +177 -0
- package/dist/utils/deduplication-cache.js.map +1 -0
- package/dist/utils/event-waiter.d.ts +101 -0
- package/dist/utils/event-waiter.d.ts.map +1 -0
- package/dist/utils/event-waiter.js +118 -0
- package/dist/utils/event-waiter.js.map +1 -0
- package/dist/utils/index.d.ts +51 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +72 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/logger.d.ts +22 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +91 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +122 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +190 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- package/dist/utils/retry-policy.d.ts +191 -0
- package/dist/utils/retry-policy.d.ts.map +1 -0
- package/dist/utils/retry-policy.js +225 -0
- package/dist/utils/retry-policy.js.map +1 -0
- package/dist/utils/secure-private-key.d.ts +113 -0
- package/dist/utils/secure-private-key.d.ts.map +1 -0
- package/dist/utils/secure-private-key.js +188 -0
- package/dist/utils/secure-private-key.js.map +1 -0
- package/dist/utils/signature-verifier.d.ts +143 -0
- package/dist/utils/signature-verifier.d.ts.map +1 -0
- package/dist/utils/signature-verifier.js +238 -0
- package/dist/utils/signature-verifier.js.map +1 -0
- package/dist/utils/ssrf-validator.d.ts +36 -0
- package/dist/utils/ssrf-validator.d.ts.map +1 -0
- package/dist/utils/ssrf-validator.js +195 -0
- package/dist/utils/ssrf-validator.js.map +1 -0
- package/examples/.env.example +17 -0
- package/examples/basic-usage.ts +211 -0
- package/examples/production-dashboard/.env.example +153 -0
- package/examples/production-dashboard/package.json +39 -0
- package/examples/production-dashboard/public/dashboard.html +642 -0
- package/examples/production-dashboard/server.ts +753 -0
- package/examples/webhook-integration.ts +239 -0
- package/examples/x-influencer-battle-redesign.html +1065 -0
- package/examples/x-influencer-battle-server.ts +217 -0
- package/examples/x-influencer-battle.html +787 -0
- package/package.json +65 -0
- package/src/constants.ts +43 -0
- package/src/core/websocket-client.test.ts +512 -0
- package/src/core/websocket-client.ts +1056 -0
- package/src/formatters/response-formatter.test.ts +571 -0
- package/src/formatters/response-formatter.ts +677 -0
- package/src/handlers/message-handler-registry.ts +239 -0
- package/src/handlers/message-handlers/agent-selected-handler.ts +40 -0
- package/src/handlers/message-handlers/agents-list-handler.ts +26 -0
- package/src/handlers/message-handlers/auth-error-handler.ts +31 -0
- package/src/handlers/message-handlers/auth-message-handler.ts +66 -0
- package/src/handlers/message-handlers/auth-required-handler.ts +23 -0
- package/src/handlers/message-handlers/auth-success-handler.ts +57 -0
- package/src/handlers/message-handlers/base-handler.ts +101 -0
- package/src/handlers/message-handlers/challenge-handler.ts +57 -0
- package/src/handlers/message-handlers/error-message-handler.ts +27 -0
- package/src/handlers/message-handlers/index.ts +77 -0
- package/src/handlers/message-handlers/list-rooms-response-handler.ts +28 -0
- package/src/handlers/message-handlers/ping-pong-handler.ts +30 -0
- package/src/handlers/message-handlers/regular-message-handler.ts +65 -0
- package/src/handlers/message-handlers/subscribe-response-handler.ts +47 -0
- package/src/handlers/message-handlers/task-response-handler.ts +45 -0
- package/src/handlers/message-handlers/types.ts +77 -0
- package/src/handlers/message-handlers/unsubscribe-response-handler.ts +47 -0
- package/src/handlers/webhook-handler.test.ts +789 -0
- package/src/handlers/webhook-handler.ts +576 -0
- package/src/index.ts +269 -0
- package/src/managers/agent-registry.test.ts +466 -0
- package/src/managers/agent-registry.ts +347 -0
- package/src/managers/connection-manager.ts +195 -0
- package/src/managers/index.ts +9 -0
- package/src/managers/message-router.ts +349 -0
- package/src/managers/room-manager.ts +248 -0
- package/src/teneo-sdk.ts +1022 -0
- package/src/types/config.test.ts +325 -0
- package/src/types/config.ts +799 -0
- package/src/types/error-codes.ts +44 -0
- package/src/types/events.test.ts +302 -0
- package/src/types/events.ts +382 -0
- package/src/types/health.ts +46 -0
- package/src/types/index.ts +199 -0
- package/src/types/messages.test.ts +660 -0
- package/src/types/messages.ts +570 -0
- package/src/types/validation.ts +123 -0
- package/src/utils/bounded-queue.test.ts +356 -0
- package/src/utils/bounded-queue.ts +205 -0
- package/src/utils/circuit-breaker.test.ts +394 -0
- package/src/utils/circuit-breaker.ts +262 -0
- package/src/utils/deduplication-cache.test.ts +380 -0
- package/src/utils/deduplication-cache.ts +198 -0
- package/src/utils/event-waiter.test.ts +381 -0
- package/src/utils/event-waiter.ts +172 -0
- package/src/utils/index.ts +74 -0
- package/src/utils/logger.ts +87 -0
- package/src/utils/rate-limiter.test.ts +341 -0
- package/src/utils/rate-limiter.ts +211 -0
- package/src/utils/retry-policy.test.ts +558 -0
- package/src/utils/retry-policy.ts +272 -0
- package/src/utils/secure-private-key.test.ts +356 -0
- package/src/utils/secure-private-key.ts +205 -0
- package/src/utils/signature-verifier.test.ts +464 -0
- package/src/utils/signature-verifier.ts +298 -0
- package/src/utils/ssrf-validator.test.ts +372 -0
- package/src/utils/ssrf-validator.ts +224 -0
- package/tests/integration/real-server.test.ts +740 -0
- package/tests/integration/websocket.test.ts +381 -0
- package/tests/integration-setup.ts +16 -0
- package/tests/setup.ts +34 -0
- package/tsconfig.json +32 -0
- package/vitest.config.ts +42 -0
- package/vitest.integration.config.ts +23 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { WebhookHandler } from "./webhook-handler";
|
|
3
|
+
import type { SDKConfig, Logger } from "../types/config";
|
|
4
|
+
import { WebhookError } from "../types/events";
|
|
5
|
+
import fetch from "node-fetch";
|
|
6
|
+
|
|
7
|
+
// Mock node-fetch
|
|
8
|
+
vi.mock("node-fetch", () => {
|
|
9
|
+
return {
|
|
10
|
+
default: vi.fn()
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("WebhookHandler", () => {
|
|
15
|
+
let handler: WebhookHandler;
|
|
16
|
+
let mockConfig: SDKConfig;
|
|
17
|
+
let mockLogger: Logger;
|
|
18
|
+
let mockFetch: any;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.useFakeTimers();
|
|
22
|
+
mockFetch = fetch as any;
|
|
23
|
+
mockFetch.mockClear();
|
|
24
|
+
|
|
25
|
+
mockConfig = {
|
|
26
|
+
wsUrl: "wss://example.com/ws",
|
|
27
|
+
privateKey: "0x1234567890123456789012345678901234567890123456789012345678901234",
|
|
28
|
+
webhookUrl: "https://webhook.example.com/events",
|
|
29
|
+
webhookHeaders: {
|
|
30
|
+
"X-API-Key": "test-key",
|
|
31
|
+
"Content-Type": "application/json"
|
|
32
|
+
},
|
|
33
|
+
webhookRetries: 3,
|
|
34
|
+
webhookTimeout: 5000,
|
|
35
|
+
logLevel: "info"
|
|
36
|
+
} as SDKConfig;
|
|
37
|
+
|
|
38
|
+
mockLogger = {
|
|
39
|
+
debug: vi.fn(),
|
|
40
|
+
info: vi.fn(),
|
|
41
|
+
warn: vi.fn(),
|
|
42
|
+
error: vi.fn()
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
handler = new WebhookHandler(mockConfig, mockLogger);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
(handler as any).circuitBreaker.reset(); // Reset circuit breaker to prevent test interference
|
|
50
|
+
handler.destroy();
|
|
51
|
+
vi.clearAllTimers();
|
|
52
|
+
vi.useRealTimers();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("constructor", () => {
|
|
56
|
+
it("should initialize with config", () => {
|
|
57
|
+
expect(handler).toBeDefined();
|
|
58
|
+
expect((handler as any).config).toStrictEqual(mockConfig);
|
|
59
|
+
expect((handler as any).queue.size()).toBe(0);
|
|
60
|
+
expect((handler as any).isProcessing).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should initialize without webhook URL", () => {
|
|
64
|
+
const configNoWebhook = { ...mockConfig, webhookUrl: undefined };
|
|
65
|
+
const handlerNoWebhook = new WebhookHandler(configNoWebhook, mockLogger);
|
|
66
|
+
expect(handlerNoWebhook).toBeDefined();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("sendWebhook", () => {
|
|
71
|
+
it("should queue webhook when URL is configured", async () => {
|
|
72
|
+
const event = "task_response";
|
|
73
|
+
const data = { content: "test message" };
|
|
74
|
+
|
|
75
|
+
await handler.sendWebhook(event, data);
|
|
76
|
+
|
|
77
|
+
expect((handler as any).queue.size()).toBe(1);
|
|
78
|
+
expect((handler as any).queue.toArray()[0].payload.event).toBe(event);
|
|
79
|
+
expect((handler as any).queue.toArray()[0].payload.data).toMatchObject(data);
|
|
80
|
+
expect((handler as any).queue.toArray()[0].attempts).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should not queue webhook when URL is not configured", async () => {
|
|
84
|
+
const configNoWebhook = { ...mockConfig, webhookUrl: undefined };
|
|
85
|
+
const handlerNoWebhook = new WebhookHandler(configNoWebhook, mockLogger);
|
|
86
|
+
|
|
87
|
+
await handlerNoWebhook.sendWebhook("task_response", { content: "test" });
|
|
88
|
+
|
|
89
|
+
expect((handlerNoWebhook as any).queue.size()).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should filter events based on webhookEvents config", async () => {
|
|
93
|
+
await handler.sendWebhook("task_response", { content: "should queue" });
|
|
94
|
+
await handler.sendWebhook("agent_selected", { agent: "should queue" });
|
|
95
|
+
await handler.sendWebhook("error", { error: "should queue" });
|
|
96
|
+
|
|
97
|
+
expect((handler as any).queue.size()).toBe(3);
|
|
98
|
+
expect((handler as any).queue.toArray()[0].payload.event).toBe("task_response");
|
|
99
|
+
expect((handler as any).queue.toArray()[1].payload.event).toBe("agent_selected");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should queue all events when webhookEvents is not configured", async () => {
|
|
103
|
+
const configAllEvents = { ...mockConfig, webhookEvents: undefined };
|
|
104
|
+
const handlerAllEvents = new WebhookHandler(configAllEvents, mockLogger);
|
|
105
|
+
|
|
106
|
+
await handlerAllEvents.sendWebhook("task_response", { content: "test1" });
|
|
107
|
+
await handlerAllEvents.sendWebhook("error", { error: "test2" });
|
|
108
|
+
await handlerAllEvents.sendWebhook("message", { custom: "test3" });
|
|
109
|
+
|
|
110
|
+
expect((handlerAllEvents as any).queue.size()).toBe(3);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should generate unique ID for each webhook", async () => {
|
|
114
|
+
await handler.sendWebhook("task_response", { content: "test1" });
|
|
115
|
+
await handler.sendWebhook("task_response", { content: "test2" });
|
|
116
|
+
|
|
117
|
+
const queue = (handler as any).queue.toArray();
|
|
118
|
+
expect(queue[0].payload.timestamp).toBeDefined();
|
|
119
|
+
expect(queue[1].payload.timestamp).toBeDefined();
|
|
120
|
+
// Each webhook gets unique timestamp
|
|
121
|
+
expect(queue[0].payload.timestamp !== queue[1].payload.timestamp || queue[0].payload.data.content !== queue[1].payload.data.content).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should include timestamp in webhook payload", async () => {
|
|
125
|
+
const beforeTime = new Date().toISOString();
|
|
126
|
+
await handler.sendWebhook("task_response", { content: "test" });
|
|
127
|
+
const afterTime = new Date().toISOString();
|
|
128
|
+
|
|
129
|
+
const webhook = (handler as any).queue.toArray()[0];
|
|
130
|
+
expect(webhook.payload.timestamp).toBeDefined();
|
|
131
|
+
expect(webhook.payload.timestamp).toBeTypeOf("string");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("processQueue", () => {
|
|
136
|
+
it.skip("should process webhooks in queue", async () => {
|
|
137
|
+
mockFetch.mockResolvedValueOnce({
|
|
138
|
+
ok: true,
|
|
139
|
+
status: 200,
|
|
140
|
+
statusText: "OK",
|
|
141
|
+
text: async () => "OK"
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await handler.sendWebhook("task_response", { content: "test" });
|
|
145
|
+
|
|
146
|
+
// Start processing
|
|
147
|
+
await (handler as any).processQueue();
|
|
148
|
+
|
|
149
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
150
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
151
|
+
"https://webhook.example.com/events",
|
|
152
|
+
expect.objectContaining({
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: expect.objectContaining({
|
|
155
|
+
"X-API-Key": "test-key",
|
|
156
|
+
"Content-Type": "application/json"
|
|
157
|
+
}),
|
|
158
|
+
body: expect.stringContaining("task_response")
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect((handler as any).queue.size()).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("should handle successful delivery", async () => {
|
|
166
|
+
mockFetch.mockResolvedValueOnce({
|
|
167
|
+
ok: true,
|
|
168
|
+
status: 200,
|
|
169
|
+
statusText: "OK",
|
|
170
|
+
text: async () => "OK"
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const successHandler = vi.fn();
|
|
174
|
+
handler.on("webhook:success", successHandler);
|
|
175
|
+
|
|
176
|
+
await handler.sendWebhook("task_response", { content: "test" });
|
|
177
|
+
await (handler as any).processQueue();
|
|
178
|
+
|
|
179
|
+
expect(successHandler).toHaveBeenCalledWith("OK", "https://webhook.example.com/events");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it.skip("should retry on failure with exponential backoff", async () => {
|
|
183
|
+
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
|
184
|
+
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
|
185
|
+
mockFetch.mockResolvedValueOnce({
|
|
186
|
+
ok: true,
|
|
187
|
+
status: 200,
|
|
188
|
+
text: async () => "OK"
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const retryHandler = vi.fn();
|
|
192
|
+
handler.on("webhook:retry", retryHandler);
|
|
193
|
+
|
|
194
|
+
await handler.sendWebhook("task_response", { content: "test" });
|
|
195
|
+
|
|
196
|
+
// First attempt
|
|
197
|
+
await (handler as any).processQueue();
|
|
198
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
199
|
+
expect((handler as any).queue.size()).toBe(1);
|
|
200
|
+
expect((handler as any).queue.toArray()[0].attempts).toBe(1);
|
|
201
|
+
|
|
202
|
+
// Wait for retry delay
|
|
203
|
+
vi.advanceTimersByTime(1000);
|
|
204
|
+
|
|
205
|
+
// Second attempt
|
|
206
|
+
await (handler as any).processQueue();
|
|
207
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
208
|
+
expect((handler as any).queue.toArray()[0].attempts).toBe(2);
|
|
209
|
+
|
|
210
|
+
// Wait for retry delay (exponential backoff)
|
|
211
|
+
vi.advanceTimersByTime(2000);
|
|
212
|
+
|
|
213
|
+
// Third attempt (success)
|
|
214
|
+
await (handler as any).processQueue();
|
|
215
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
216
|
+
expect((handler as any).queue.size()).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it.skip("should fail after max retry attempts", async () => {
|
|
220
|
+
mockFetch.mockRejectedValue(new Error("Persistent error"));
|
|
221
|
+
|
|
222
|
+
const errorHandler = vi.fn();
|
|
223
|
+
handler.on("webhook:error", errorHandler);
|
|
224
|
+
|
|
225
|
+
await handler.sendWebhook("task_response", { content: "test" });
|
|
226
|
+
|
|
227
|
+
// Attempt all retries
|
|
228
|
+
for (let i = 0; i <= mockConfig.webhookRetries!; i++) {
|
|
229
|
+
await (handler as any).processQueue();
|
|
230
|
+
if (i < mockConfig.webhookRetries!) {
|
|
231
|
+
vi.advanceTimersByTime(1000 * Math.pow(2, i));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
expect(mockFetch).toHaveBeenCalledTimes(4); // Initial + 3 retries
|
|
236
|
+
expect(errorHandler).toHaveBeenCalledWith(
|
|
237
|
+
expect.any(Error),
|
|
238
|
+
"https://webhook.example.com/events"
|
|
239
|
+
);
|
|
240
|
+
expect((handler as any).queue.size()).toBe(0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it.skip("should handle timeout", async () => {
|
|
244
|
+
// Mock a delayed response
|
|
245
|
+
mockFetch.mockImplementationOnce(
|
|
246
|
+
() =>
|
|
247
|
+
new Promise((resolve) => {
|
|
248
|
+
setTimeout(() => resolve({ ok: true, status: 200, text: async () => "OK" }), 10000);
|
|
249
|
+
})
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const errorHandler = vi.fn();
|
|
253
|
+
handler.on("webhook:error", errorHandler);
|
|
254
|
+
|
|
255
|
+
await handler.sendWebhook("task_response", { content: "test" });
|
|
256
|
+
|
|
257
|
+
// Start processing
|
|
258
|
+
const processPromise = (handler as any).processQueue();
|
|
259
|
+
|
|
260
|
+
// Advance timers past timeout
|
|
261
|
+
vi.advanceTimersByTime(5100);
|
|
262
|
+
|
|
263
|
+
await processPromise;
|
|
264
|
+
|
|
265
|
+
// Should have retried
|
|
266
|
+
expect((handler as any).queue.size()).toBe(1);
|
|
267
|
+
expect((handler as any).queue.toArray()[0].attempts).toBe(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it.skip("should handle HTTP error responses", async () => {
|
|
271
|
+
mockFetch.mockResolvedValueOnce({
|
|
272
|
+
ok: false,
|
|
273
|
+
status: 500,
|
|
274
|
+
statusText: "Internal Server Error",
|
|
275
|
+
text: async () => "Internal Server Error"
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const retryHandler = vi.fn();
|
|
279
|
+
handler.on("webhook:retry", retryHandler);
|
|
280
|
+
|
|
281
|
+
await handler.sendWebhook("task_response", { content: "test" });
|
|
282
|
+
await (handler as any).processQueue();
|
|
283
|
+
|
|
284
|
+
expect(retryHandler).toHaveBeenCalled();
|
|
285
|
+
expect((handler as any).queue.size()).toBe(1);
|
|
286
|
+
expect((handler as any).queue.toArray()[0].attempts).toBe(1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("should process multiple webhooks in sequence", async () => {
|
|
290
|
+
mockFetch.mockResolvedValue({
|
|
291
|
+
ok: true,
|
|
292
|
+
status: 200,
|
|
293
|
+
text: async () => "OK"
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await handler.sendWebhook("task_response", { content: "test1" });
|
|
297
|
+
await handler.sendWebhook("agent_selected", { agent: "test2" });
|
|
298
|
+
await handler.sendWebhook("task_response", { content: "test3" });
|
|
299
|
+
|
|
300
|
+
// Process all webhooks
|
|
301
|
+
while ((handler as any).queue.size() > 0) {
|
|
302
|
+
await (handler as any).processQueue();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
306
|
+
expect((handler as any).queue.size()).toBe(0);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("deliverWebhook", () => {
|
|
311
|
+
it("should send webhook with correct payload", async () => {
|
|
312
|
+
mockFetch.mockResolvedValueOnce({
|
|
313
|
+
ok: true,
|
|
314
|
+
status: 200,
|
|
315
|
+
text: async () => "OK"
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const webhookItem = {
|
|
319
|
+
payload: {
|
|
320
|
+
event: "task_response",
|
|
321
|
+
timestamp: new Date().toISOString(),
|
|
322
|
+
data: { content: "test" }
|
|
323
|
+
},
|
|
324
|
+
attempts: 0
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
await (handler as any).deliverWebhook(webhookItem);
|
|
328
|
+
|
|
329
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
330
|
+
"https://webhook.example.com/events",
|
|
331
|
+
expect.objectContaining({
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers: expect.objectContaining({
|
|
334
|
+
"X-API-Key": "test-key",
|
|
335
|
+
"Content-Type": "application/json"
|
|
336
|
+
}),
|
|
337
|
+
body: expect.stringContaining("task_response")
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("should merge custom headers", async () => {
|
|
343
|
+
mockFetch.mockResolvedValueOnce({
|
|
344
|
+
ok: true,
|
|
345
|
+
status: 200,
|
|
346
|
+
text: async () => "OK"
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const webhookItem = {
|
|
350
|
+
payload: {
|
|
351
|
+
event: "message",
|
|
352
|
+
timestamp: new Date().toISOString(),
|
|
353
|
+
data: {}
|
|
354
|
+
},
|
|
355
|
+
attempts: 0
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
await (handler as any).deliverWebhook(webhookItem);
|
|
359
|
+
|
|
360
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
361
|
+
expect.any(String),
|
|
362
|
+
expect.objectContaining({
|
|
363
|
+
headers: expect.objectContaining({
|
|
364
|
+
"X-API-Key": "test-key",
|
|
365
|
+
"Content-Type": "application/json"
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it.skip("should handle AbortController for timeout", async () => {
|
|
372
|
+
mockFetch.mockImplementationOnce(
|
|
373
|
+
() =>
|
|
374
|
+
new Promise((_, reject) => {
|
|
375
|
+
const error = new Error("Timeout");
|
|
376
|
+
error.name = "AbortError";
|
|
377
|
+
setTimeout(() => reject(error), 100);
|
|
378
|
+
})
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const webhookItem = {
|
|
382
|
+
payload: {
|
|
383
|
+
event: "message",
|
|
384
|
+
timestamp: new Date().toISOString(),
|
|
385
|
+
data: {}
|
|
386
|
+
},
|
|
387
|
+
attempts: 0
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
await expect((handler as any).deliverWebhook(webhookItem)).rejects.toThrow();
|
|
391
|
+
}, 15000);
|
|
392
|
+
|
|
393
|
+
it("should validate successful response", async () => {
|
|
394
|
+
mockFetch.mockResolvedValueOnce({
|
|
395
|
+
ok: true,
|
|
396
|
+
status: 200,
|
|
397
|
+
text: async () => "OK"
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const webhookItem = {
|
|
401
|
+
payload: {
|
|
402
|
+
event: "message",
|
|
403
|
+
timestamp: new Date().toISOString(),
|
|
404
|
+
data: {}
|
|
405
|
+
},
|
|
406
|
+
attempts: 0
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
await (handler as any).deliverWebhook(webhookItem);
|
|
410
|
+
// Should not throw
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("should handle non-2xx responses as failure", async () => {
|
|
414
|
+
mockFetch.mockResolvedValueOnce({
|
|
415
|
+
ok: false,
|
|
416
|
+
status: 404,
|
|
417
|
+
statusText: "Not Found",
|
|
418
|
+
text: async () => "Not Found"
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const webhookItem = {
|
|
422
|
+
payload: {
|
|
423
|
+
event: "message",
|
|
424
|
+
timestamp: new Date().toISOString(),
|
|
425
|
+
data: {}
|
|
426
|
+
},
|
|
427
|
+
attempts: 0
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
await expect((handler as any).deliverWebhook(webhookItem)).rejects.toThrow(WebhookError);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
describe("validateWebhookUrl - SSRF Protection", () => {
|
|
435
|
+
describe("Valid URLs", () => {
|
|
436
|
+
it("should accept HTTPS public URLs", () => {
|
|
437
|
+
const urls = ["https://webhook.example.com/events", "https://api.example.org/webhook"];
|
|
438
|
+
|
|
439
|
+
urls.forEach((url) => {
|
|
440
|
+
expect(() => (handler as any).validateWebhookUrl(url)).not.toThrow();
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("should accept HTTP localhost URLs with allowInsecureWebhooks", () => {
|
|
445
|
+
const configWithInsecure = { ...mockConfig, allowInsecureWebhooks: true };
|
|
446
|
+
const handlerWithInsecure = new WebhookHandler(configWithInsecure, mockLogger);
|
|
447
|
+
|
|
448
|
+
const urls = [
|
|
449
|
+
"http://localhost/webhook",
|
|
450
|
+
"http://localhost:3000/events",
|
|
451
|
+
"http://127.0.0.1/webhook",
|
|
452
|
+
"http://127.0.0.1:8080/events"
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
urls.forEach((url) => {
|
|
456
|
+
expect(() => (handlerWithInsecure as any).validateWebhookUrl(url)).not.toThrow();
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe("SSRF Protection - Cloud Metadata", () => {
|
|
462
|
+
it("should block AWS metadata endpoints", () => {
|
|
463
|
+
const configWithInsecure = { ...mockConfig, allowInsecureWebhooks: true };
|
|
464
|
+
const handlerWithInsecure = new WebhookHandler(configWithInsecure, mockLogger);
|
|
465
|
+
|
|
466
|
+
const urls = [
|
|
467
|
+
"http://169.254.169.254/latest/meta-data/",
|
|
468
|
+
"https://169.254.169.254/latest/meta-data/iam/security-credentials/"
|
|
469
|
+
];
|
|
470
|
+
|
|
471
|
+
urls.forEach((url) => {
|
|
472
|
+
expect(() => (handlerWithInsecure as any).validateWebhookUrl(url)).toThrow(WebhookError);
|
|
473
|
+
expect(() => (handlerWithInsecure as any).validateWebhookUrl(url)).toThrow(
|
|
474
|
+
/cloud metadata endpoint/i
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("should block Google Cloud metadata endpoints", () => {
|
|
480
|
+
const configWithInsecure = { ...mockConfig, allowInsecureWebhooks: true };
|
|
481
|
+
const handlerWithInsecure = new WebhookHandler(configWithInsecure, mockLogger);
|
|
482
|
+
|
|
483
|
+
const urls = [
|
|
484
|
+
"http://metadata.google.internal/computeMetadata/v1/",
|
|
485
|
+
"http://metadata.google.com/computeMetadata/v1/"
|
|
486
|
+
];
|
|
487
|
+
|
|
488
|
+
urls.forEach((url) => {
|
|
489
|
+
expect(() => (handlerWithInsecure as any).validateWebhookUrl(url)).toThrow(WebhookError);
|
|
490
|
+
expect(() => (handlerWithInsecure as any).validateWebhookUrl(url)).toThrow(
|
|
491
|
+
/cloud metadata/i
|
|
492
|
+
);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("should block Kubernetes services", () => {
|
|
497
|
+
const configWithInsecure = { ...mockConfig, allowInsecureWebhooks: true };
|
|
498
|
+
const handlerWithInsecure = new WebhookHandler(configWithInsecure, mockLogger);
|
|
499
|
+
|
|
500
|
+
const urls = [
|
|
501
|
+
"http://kubernetes.default/api",
|
|
502
|
+
"http://redis.default.svc.cluster.local:6379"
|
|
503
|
+
];
|
|
504
|
+
|
|
505
|
+
urls.forEach((url) => {
|
|
506
|
+
expect(() => (handlerWithInsecure as any).validateWebhookUrl(url)).toThrow(WebhookError);
|
|
507
|
+
expect(() => (handlerWithInsecure as any).validateWebhookUrl(url)).toThrow(
|
|
508
|
+
/Kubernetes service|cloud metadata/i
|
|
509
|
+
);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
describe("SSRF Protection - Private IPs", () => {
|
|
515
|
+
it("should block RFC1918 private IP ranges", () => {
|
|
516
|
+
const urls = [
|
|
517
|
+
"https://10.0.0.1/webhook",
|
|
518
|
+
"https://192.168.1.1/webhook",
|
|
519
|
+
"https://172.16.0.1/webhook",
|
|
520
|
+
"https://172.31.255.255/webhook"
|
|
521
|
+
];
|
|
522
|
+
|
|
523
|
+
urls.forEach((url) => {
|
|
524
|
+
expect(() => (handler as any).validateWebhookUrl(url)).toThrow(WebhookError);
|
|
525
|
+
expect(() => (handler as any).validateWebhookUrl(url)).toThrow(/private IP address/i);
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("should block link-local addresses", () => {
|
|
530
|
+
const urls = ["https://169.254.1.1/webhook", "https://169.254.255.255/webhook"];
|
|
531
|
+
|
|
532
|
+
urls.forEach((url) => {
|
|
533
|
+
expect(() => (handler as any).validateWebhookUrl(url)).toThrow(WebhookError);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("should block IPv6 private ranges", () => {
|
|
538
|
+
const configWithInsecure = { ...mockConfig, allowInsecureWebhooks: true };
|
|
539
|
+
const handlerWithInsecure = new WebhookHandler(configWithInsecure, mockLogger);
|
|
540
|
+
|
|
541
|
+
const urls = [
|
|
542
|
+
"http://[fc00::1]/webhook",
|
|
543
|
+
"http://[fd00::1]/webhook",
|
|
544
|
+
"http://[fe80::1]/webhook"
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
urls.forEach((url) => {
|
|
548
|
+
expect(() => (handlerWithInsecure as any).validateWebhookUrl(url)).toThrow(WebhookError);
|
|
549
|
+
expect(() => (handlerWithInsecure as any).validateWebhookUrl(url)).toThrow(/private IP/i);
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
describe("SSRF Protection - Dangerous Ports", () => {
|
|
555
|
+
it("should block common internal service ports", () => {
|
|
556
|
+
const urls = [
|
|
557
|
+
"https://example.com:22/webhook", // SSH
|
|
558
|
+
"https://example.com:3306/webhook", // MySQL
|
|
559
|
+
"https://example.com:5432/webhook", // PostgreSQL
|
|
560
|
+
"https://example.com:6379/webhook", // Redis
|
|
561
|
+
"https://example.com:27017/webhook" // MongoDB
|
|
562
|
+
];
|
|
563
|
+
|
|
564
|
+
urls.forEach((url) => {
|
|
565
|
+
expect(() => (handler as any).validateWebhookUrl(url)).toThrow(WebhookError);
|
|
566
|
+
expect(() => (handler as any).validateWebhookUrl(url)).toThrow(
|
|
567
|
+
/Port.*internal services/i
|
|
568
|
+
);
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("should allow safe ports", () => {
|
|
573
|
+
const urls = [
|
|
574
|
+
"https://example.com:443/webhook",
|
|
575
|
+
"https://example.com:8443/webhook",
|
|
576
|
+
"https://example.com:3000/webhook"
|
|
577
|
+
];
|
|
578
|
+
|
|
579
|
+
urls.forEach((url) => {
|
|
580
|
+
expect(() => (handler as any).validateWebhookUrl(url)).not.toThrow();
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
describe("Protocol Validation", () => {
|
|
586
|
+
it("should reject HTTP for non-localhost without allowInsecureWebhooks", () => {
|
|
587
|
+
const urls = [
|
|
588
|
+
"http://example.com/webhook"
|
|
589
|
+
];
|
|
590
|
+
|
|
591
|
+
urls.forEach((url) => {
|
|
592
|
+
expect(() => (handler as any).validateWebhookUrl(url)).toThrow(WebhookError);
|
|
593
|
+
expect(() => (handler as any).validateWebhookUrl(url)).toThrow(/must use HTTPS|Only HTTPS/i);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// Private IPs are blocked for different reason (private IP, not HTTPS requirement)
|
|
597
|
+
const privateUrls = [
|
|
598
|
+
"http://192.168.1.1/webhook",
|
|
599
|
+
"http://10.0.0.1/webhook"
|
|
600
|
+
];
|
|
601
|
+
privateUrls.forEach((url) => {
|
|
602
|
+
expect(() => (handler as any).validateWebhookUrl(url)).toThrow(WebhookError);
|
|
603
|
+
expect(() => (handler as any).validateWebhookUrl(url)).toThrow(/private IP/i);
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("should reject non-HTTP/HTTPS protocols", () => {
|
|
608
|
+
const urls = ["ftp://example.com/webhook", "file:///etc/passwd", "javascript:alert(1)"];
|
|
609
|
+
|
|
610
|
+
urls.forEach((url) => {
|
|
611
|
+
expect(() => (handler as any).validateWebhookUrl(url)).toThrow();
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
describe("Invalid URLs", () => {
|
|
617
|
+
it("should reject malformed URLs", () => {
|
|
618
|
+
const urls = ["not-a-url", "", "htp://example.com"];
|
|
619
|
+
|
|
620
|
+
urls.forEach((url) => {
|
|
621
|
+
expect(() => (handler as any).validateWebhookUrl(url)).toThrow();
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
describe("destroy", () => {
|
|
628
|
+
it("should clear the queue", async () => {
|
|
629
|
+
await handler.sendWebhook("task_response", { content: "test1" });
|
|
630
|
+
await handler.sendWebhook("agent_selected", { agent: "test2" });
|
|
631
|
+
|
|
632
|
+
expect((handler as any).queue.size()).toBe(2);
|
|
633
|
+
|
|
634
|
+
handler.destroy();
|
|
635
|
+
|
|
636
|
+
expect((handler as any).queue.size()).toBe(0);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("should stop processing", async () => {
|
|
640
|
+
(handler as any).isProcessing = true;
|
|
641
|
+
|
|
642
|
+
handler.destroy();
|
|
643
|
+
|
|
644
|
+
expect((handler as any).isProcessing).toBe(false);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("should be safe to call multiple times", async () => {
|
|
648
|
+
await handler.sendWebhook("task_response", { content: "test" });
|
|
649
|
+
|
|
650
|
+
handler.destroy();
|
|
651
|
+
handler.destroy();
|
|
652
|
+
handler.destroy();
|
|
653
|
+
|
|
654
|
+
expect((handler as any).queue.size()).toBe(0);
|
|
655
|
+
expect((handler as any).isProcessing).toBe(false);
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
describe("event emission", () => {
|
|
660
|
+
it("should emit webhook:sent event", async () => {
|
|
661
|
+
mockFetch.mockResolvedValueOnce({
|
|
662
|
+
ok: true,
|
|
663
|
+
status: 200,
|
|
664
|
+
text: async () => "OK"
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
const sentHandler = vi.fn();
|
|
668
|
+
handler.on("webhook:sent", sentHandler);
|
|
669
|
+
|
|
670
|
+
await handler.sendWebhook("task_response", { content: "test" });
|
|
671
|
+
await (handler as any).processQueue();
|
|
672
|
+
|
|
673
|
+
expect(sentHandler).toHaveBeenCalledWith(
|
|
674
|
+
expect.objectContaining({
|
|
675
|
+
event: "task_response"
|
|
676
|
+
}),
|
|
677
|
+
"https://webhook.example.com/events"
|
|
678
|
+
);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it("should emit webhook:success event", async () => {
|
|
682
|
+
mockFetch.mockResolvedValueOnce({
|
|
683
|
+
ok: true,
|
|
684
|
+
status: 201,
|
|
685
|
+
text: async () => "Created"
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const successHandler = vi.fn();
|
|
689
|
+
handler.on("webhook:success", successHandler);
|
|
690
|
+
|
|
691
|
+
await handler.sendWebhook("task_response", { content: "test" });
|
|
692
|
+
await (handler as any).processQueue();
|
|
693
|
+
|
|
694
|
+
expect(successHandler).toHaveBeenCalledWith("Created", "https://webhook.example.com/events");
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it.skip("should emit webhook:retry event", async () => {
|
|
698
|
+
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
|
699
|
+
|
|
700
|
+
const retryHandler = vi.fn();
|
|
701
|
+
handler.on("webhook:retry", retryHandler);
|
|
702
|
+
|
|
703
|
+
await handler.sendWebhook("task_response", { content: "test" });
|
|
704
|
+
await (handler as any).processQueue();
|
|
705
|
+
|
|
706
|
+
expect(retryHandler).toHaveBeenCalledWith(1, "https://webhook.example.com/events");
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it.skip("should emit webhook:error event", async () => {
|
|
710
|
+
mockFetch.mockRejectedValue(new Error("Persistent error"));
|
|
711
|
+
|
|
712
|
+
const errorHandler = vi.fn();
|
|
713
|
+
handler.on("webhook:error", errorHandler);
|
|
714
|
+
|
|
715
|
+
await handler.sendWebhook("task_response", { content: "test" });
|
|
716
|
+
|
|
717
|
+
// Exhaust all retries
|
|
718
|
+
for (let i = 0; i <= mockConfig.webhookRetries!; i++) {
|
|
719
|
+
await (handler as any).processQueue();
|
|
720
|
+
vi.advanceTimersByTime(10000);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
expect(errorHandler).toHaveBeenCalledWith(expect.any(Error), "https://webhook.example.com/events");
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
describe("edge cases", () => {
|
|
728
|
+
it("should handle empty data payload", async () => {
|
|
729
|
+
mockFetch.mockResolvedValueOnce({
|
|
730
|
+
ok: true,
|
|
731
|
+
status: 200,
|
|
732
|
+
text: async () => "OK"
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
await handler.sendWebhook("message", undefined as any);
|
|
736
|
+
await (handler as any).processQueue();
|
|
737
|
+
|
|
738
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
739
|
+
expect.any(String),
|
|
740
|
+
expect.objectContaining({
|
|
741
|
+
body: expect.any(String)
|
|
742
|
+
})
|
|
743
|
+
);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it("should handle large payloads", async () => {
|
|
747
|
+
mockFetch.mockResolvedValueOnce({
|
|
748
|
+
ok: true,
|
|
749
|
+
status: 200,
|
|
750
|
+
text: async () => "OK"
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
const largeData = {
|
|
754
|
+
content: "x".repeat(100000), // 100KB string
|
|
755
|
+
array: Array(1000).fill({ nested: "object" })
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
await handler.sendWebhook("task_response", largeData);
|
|
759
|
+
await (handler as any).processQueue();
|
|
760
|
+
|
|
761
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
762
|
+
const call = mockFetch.mock.calls[0];
|
|
763
|
+
const body = JSON.parse(call[1].body);
|
|
764
|
+
expect(body.data.content.length).toBe(100000);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it.skip("should handle concurrent webhook sends", async () => {
|
|
768
|
+
mockFetch.mockResolvedValue({
|
|
769
|
+
ok: true,
|
|
770
|
+
status: 200,
|
|
771
|
+
text: async () => "OK"
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// Send multiple webhooks rapidly
|
|
775
|
+
for (let i = 0; i < 10; i++) {
|
|
776
|
+
await handler.sendWebhook("task_response", { index: i });
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
expect((handler as any).queue.size()).toBe(10);
|
|
780
|
+
|
|
781
|
+
// Process all
|
|
782
|
+
while ((handler as any).queue.size() > 0) {
|
|
783
|
+
await (handler as any).processQueue();
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
expect(mockFetch).toHaveBeenCalledTimes(10);
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
});
|