@vellumai/vellum-gateway 0.1.7
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 +7 -0
- package/.env.example +59 -0
- package/Dockerfile +44 -0
- package/README.md +186 -0
- package/bun.lock +391 -0
- package/eslint.config.mjs +23 -0
- package/knip.json +8 -0
- package/package.json +27 -0
- package/src/__tests__/bearer-auth.test.ts +40 -0
- package/src/__tests__/config.test.ts +236 -0
- package/src/__tests__/dedup-cache.test.ts +101 -0
- package/src/__tests__/load-guards.test.ts +86 -0
- package/src/__tests__/probes.test.ts +94 -0
- package/src/__tests__/reply-path.test.ts +51 -0
- package/src/__tests__/resolve-assistant.test.ts +118 -0
- package/src/__tests__/runtime-client.test.ts +228 -0
- package/src/__tests__/runtime-proxy-auth.test.ts +127 -0
- package/src/__tests__/runtime-proxy.test.ts +262 -0
- package/src/__tests__/schema.test.ts +128 -0
- package/src/__tests__/telegram-normalize.test.ts +303 -0
- package/src/__tests__/telegram-only-default.test.ts +134 -0
- package/src/__tests__/telegram-send-attachments.test.ts +185 -0
- package/src/cli/schema.ts +8 -0
- package/src/config.ts +254 -0
- package/src/dedup-cache.ts +104 -0
- package/src/handlers/handle-inbound.ts +104 -0
- package/src/http/auth/bearer.ts +34 -0
- package/src/http/routes/runtime-proxy.ts +143 -0
- package/src/http/routes/telegram-webhook.ts +272 -0
- package/src/index.ts +117 -0
- package/src/logger.ts +103 -0
- package/src/routing/resolve-assistant.ts +45 -0
- package/src/routing/types.ts +11 -0
- package/src/runtime/client.ts +212 -0
- package/src/schema.ts +383 -0
- package/src/telegram/api.ts +153 -0
- package/src/telegram/download.ts +63 -0
- package/src/telegram/normalize.ts +118 -0
- package/src/telegram/send.ts +107 -0
- package/src/telegram/verify.ts +17 -0
- package/src/types.ts +37 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, test, expect, afterAll } from "bun:test";
|
|
2
|
+
import { buildSchema } from "../schema.js";
|
|
3
|
+
|
|
4
|
+
const PORT = 19836;
|
|
5
|
+
|
|
6
|
+
const server = Bun.serve({
|
|
7
|
+
port: PORT,
|
|
8
|
+
fetch(req) {
|
|
9
|
+
const url = new URL(req.url);
|
|
10
|
+
|
|
11
|
+
if (url.pathname === "/schema") {
|
|
12
|
+
return Response.json(buildSchema());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
server.stop(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("/schema route", () => {
|
|
24
|
+
test("returns valid OpenAPI 3.1 schema via HTTP", async () => {
|
|
25
|
+
/**
|
|
26
|
+
* Tests that the /schema endpoint returns a valid OpenAPI document with
|
|
27
|
+
* the correct version and expected top-level structure.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// GIVEN a running gateway server
|
|
31
|
+
|
|
32
|
+
// WHEN we request the schema endpoint
|
|
33
|
+
const res = await fetch(`http://localhost:${PORT}/schema`);
|
|
34
|
+
|
|
35
|
+
// THEN we receive a 200 with valid JSON
|
|
36
|
+
expect(res.status).toBe(200);
|
|
37
|
+
const body = await res.json();
|
|
38
|
+
|
|
39
|
+
// AND the response is an OpenAPI 3.1 document
|
|
40
|
+
expect(body.openapi).toBe("3.1.0");
|
|
41
|
+
expect(body.info.title).toBe("Vellum Gateway");
|
|
42
|
+
expect(typeof body.info.version).toBe("string");
|
|
43
|
+
|
|
44
|
+
// AND it contains the expected top-level sections
|
|
45
|
+
expect(body.paths).toBeDefined();
|
|
46
|
+
expect(body.components).toBeDefined();
|
|
47
|
+
expect(body.components.schemas).toBeDefined();
|
|
48
|
+
expect(body.components.securitySchemes).toBeDefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("schema includes all gateway routes", async () => {
|
|
52
|
+
/**
|
|
53
|
+
* Tests that the schema documents every route the gateway exposes.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
// GIVEN a running gateway server
|
|
57
|
+
|
|
58
|
+
// WHEN we request the schema endpoint
|
|
59
|
+
const res = await fetch(`http://localhost:${PORT}/schema`);
|
|
60
|
+
const body = await res.json();
|
|
61
|
+
|
|
62
|
+
// THEN the paths include every gateway endpoint
|
|
63
|
+
expect(body.paths["/healthz"]).toBeDefined();
|
|
64
|
+
expect(body.paths["/readyz"]).toBeDefined();
|
|
65
|
+
expect(body.paths["/schema"]).toBeDefined();
|
|
66
|
+
expect(body.paths["/webhooks/telegram"]).toBeDefined();
|
|
67
|
+
expect(body.paths["/{path}"]).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("schema version matches package.json version", async () => {
|
|
71
|
+
/**
|
|
72
|
+
* Tests that the schema info.version stays in sync with package.json.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
// GIVEN the version from package.json
|
|
76
|
+
const pkg = (await import("../../package.json")).default;
|
|
77
|
+
|
|
78
|
+
// WHEN we request the schema endpoint
|
|
79
|
+
const res = await fetch(`http://localhost:${PORT}/schema`);
|
|
80
|
+
const body = await res.json();
|
|
81
|
+
|
|
82
|
+
// THEN the schema version matches the package version
|
|
83
|
+
expect(body.info.version).toBe(pkg.version);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("buildSchema()", () => {
|
|
88
|
+
test("returns a plain object with all component schemas", () => {
|
|
89
|
+
/**
|
|
90
|
+
* Tests that buildSchema() includes all expected component schema
|
|
91
|
+
* definitions for request/response types.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
// GIVEN no special setup needed
|
|
95
|
+
|
|
96
|
+
// WHEN we call buildSchema directly
|
|
97
|
+
const schema = buildSchema();
|
|
98
|
+
|
|
99
|
+
// THEN it contains all expected component schemas
|
|
100
|
+
const components = schema.components as Record<string, Record<string, unknown>>;
|
|
101
|
+
const schemaNames = Object.keys(components.schemas);
|
|
102
|
+
expect(schemaNames).toContain("HealthResponse");
|
|
103
|
+
expect(schemaNames).toContain("ReadyResponse");
|
|
104
|
+
expect(schemaNames).toContain("DrainingResponse");
|
|
105
|
+
expect(schemaNames).toContain("ErrorResponse");
|
|
106
|
+
expect(schemaNames).toContain("TelegramOk");
|
|
107
|
+
expect(schemaNames).toContain("TelegramUpdate");
|
|
108
|
+
expect(schemaNames).toContain("TelegramMessage");
|
|
109
|
+
expect(schemaNames).toContain("TelegramPhotoSize");
|
|
110
|
+
expect(schemaNames).toContain("TelegramDocument");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("returns a JSON-serializable object", () => {
|
|
114
|
+
/**
|
|
115
|
+
* Tests that the schema can be round-tripped through JSON without loss.
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
// GIVEN no special setup needed
|
|
119
|
+
|
|
120
|
+
// WHEN we serialize and deserialize the schema
|
|
121
|
+
const schema = buildSchema();
|
|
122
|
+
const json = JSON.stringify(schema);
|
|
123
|
+
const parsed = JSON.parse(json);
|
|
124
|
+
|
|
125
|
+
// THEN the round-tripped object equals the original
|
|
126
|
+
expect(parsed).toEqual(schema);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { normalizeTelegramUpdate } from "../telegram/normalize.js";
|
|
3
|
+
import { verifyWebhookSecret } from "../telegram/verify.js";
|
|
4
|
+
|
|
5
|
+
describe("normalizeTelegramUpdate", () => {
|
|
6
|
+
const validPayload = {
|
|
7
|
+
update_id: 123456,
|
|
8
|
+
message: {
|
|
9
|
+
message_id: 42,
|
|
10
|
+
text: "Hello bot",
|
|
11
|
+
chat: { id: 99001, type: "private" },
|
|
12
|
+
from: {
|
|
13
|
+
id: 55001,
|
|
14
|
+
is_bot: false,
|
|
15
|
+
username: "testuser",
|
|
16
|
+
first_name: "Test",
|
|
17
|
+
last_name: "User",
|
|
18
|
+
language_code: "en",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
test("normalizes a valid private text message", () => {
|
|
24
|
+
const result = normalizeTelegramUpdate(validPayload);
|
|
25
|
+
expect(result).not.toBeNull();
|
|
26
|
+
expect(result!.version).toBe("v1");
|
|
27
|
+
expect(result!.sourceChannel).toBe("telegram");
|
|
28
|
+
expect(result!.message.content).toBe("Hello bot");
|
|
29
|
+
expect(result!.message.externalChatId).toBe("99001");
|
|
30
|
+
expect(result!.message.externalMessageId).toBe("123456");
|
|
31
|
+
expect(result!.sender.externalUserId).toBe("55001");
|
|
32
|
+
expect(result!.sender.username).toBe("testuser");
|
|
33
|
+
expect(result!.sender.displayName).toBe("Test User");
|
|
34
|
+
expect(result!.sender.firstName).toBe("Test");
|
|
35
|
+
expect(result!.sender.lastName).toBe("User");
|
|
36
|
+
expect(result!.sender.languageCode).toBe("en");
|
|
37
|
+
expect(result!.sender.isBot).toBe(false);
|
|
38
|
+
expect(result!.source.updateId).toBe("123456");
|
|
39
|
+
expect(result!.source.messageId).toBe("42");
|
|
40
|
+
expect(result!.source.chatType).toBe("private");
|
|
41
|
+
expect(result!.raw).toEqual(validPayload);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns null for unsupported message types (e.g. sticker-only)", () => {
|
|
45
|
+
const payload = {
|
|
46
|
+
update_id: 1,
|
|
47
|
+
message: {
|
|
48
|
+
message_id: 1,
|
|
49
|
+
chat: { id: 1, type: "private" },
|
|
50
|
+
sticker: { file_id: "abc" },
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
expect(normalizeTelegramUpdate(payload)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("normalizes a photo message", () => {
|
|
57
|
+
const payload = {
|
|
58
|
+
update_id: 100,
|
|
59
|
+
message: {
|
|
60
|
+
message_id: 10,
|
|
61
|
+
chat: { id: 200, type: "private" },
|
|
62
|
+
from: { id: 300, is_bot: false, username: "photouser", first_name: "Photo" },
|
|
63
|
+
photo: [
|
|
64
|
+
{ file_id: "small_id", file_unique_id: "s1", width: 90, height: 90 },
|
|
65
|
+
{ file_id: "medium_id", file_unique_id: "s2", width: 320, height: 320 },
|
|
66
|
+
{ file_id: "large_id", file_unique_id: "s3", width: 800, height: 800 },
|
|
67
|
+
],
|
|
68
|
+
caption: "Check this out",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
const result = normalizeTelegramUpdate(payload);
|
|
72
|
+
expect(result).not.toBeNull();
|
|
73
|
+
expect(result!.message.content).toBe("Check this out");
|
|
74
|
+
expect(result!.message.attachments).toHaveLength(1);
|
|
75
|
+
expect(result!.message.attachments![0]).toEqual({
|
|
76
|
+
type: "photo",
|
|
77
|
+
fileId: "large_id",
|
|
78
|
+
fileSize: undefined,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("normalizes a photo message without caption", () => {
|
|
83
|
+
const payload = {
|
|
84
|
+
update_id: 101,
|
|
85
|
+
message: {
|
|
86
|
+
message_id: 11,
|
|
87
|
+
chat: { id: 200, type: "private" },
|
|
88
|
+
from: { id: 300, is_bot: false },
|
|
89
|
+
photo: [
|
|
90
|
+
{ file_id: "only_id", file_unique_id: "s1", width: 800, height: 800 },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
const result = normalizeTelegramUpdate(payload);
|
|
95
|
+
expect(result).not.toBeNull();
|
|
96
|
+
expect(result!.message.content).toBe("");
|
|
97
|
+
expect(result!.message.attachments).toHaveLength(1);
|
|
98
|
+
expect(result!.message.attachments![0].fileId).toBe("only_id");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("normalizes a document message", () => {
|
|
102
|
+
const payload = {
|
|
103
|
+
update_id: 102,
|
|
104
|
+
message: {
|
|
105
|
+
message_id: 12,
|
|
106
|
+
chat: { id: 200, type: "private" },
|
|
107
|
+
from: { id: 300, is_bot: false, username: "docuser" },
|
|
108
|
+
document: {
|
|
109
|
+
file_id: "doc_file_id",
|
|
110
|
+
file_unique_id: "du1",
|
|
111
|
+
file_name: "report.pdf",
|
|
112
|
+
mime_type: "application/pdf",
|
|
113
|
+
file_size: 12345,
|
|
114
|
+
},
|
|
115
|
+
caption: "Here is the report",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
const result = normalizeTelegramUpdate(payload);
|
|
119
|
+
expect(result).not.toBeNull();
|
|
120
|
+
expect(result!.message.content).toBe("Here is the report");
|
|
121
|
+
expect(result!.message.attachments).toHaveLength(1);
|
|
122
|
+
expect(result!.message.attachments![0]).toEqual({
|
|
123
|
+
type: "document",
|
|
124
|
+
fileId: "doc_file_id",
|
|
125
|
+
fileName: "report.pdf",
|
|
126
|
+
mimeType: "application/pdf",
|
|
127
|
+
fileSize: 12345,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("normalizes a document message without caption", () => {
|
|
132
|
+
const payload = {
|
|
133
|
+
update_id: 103,
|
|
134
|
+
message: {
|
|
135
|
+
message_id: 13,
|
|
136
|
+
chat: { id: 200, type: "private" },
|
|
137
|
+
from: { id: 300, is_bot: false },
|
|
138
|
+
document: {
|
|
139
|
+
file_id: "doc_id_2",
|
|
140
|
+
file_unique_id: "du2",
|
|
141
|
+
file_name: "data.csv",
|
|
142
|
+
mime_type: "text/csv",
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
const result = normalizeTelegramUpdate(payload);
|
|
147
|
+
expect(result).not.toBeNull();
|
|
148
|
+
expect(result!.message.content).toBe("");
|
|
149
|
+
expect(result!.message.attachments).toHaveLength(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("text-only messages have no attachments field", () => {
|
|
153
|
+
const result = normalizeTelegramUpdate(validPayload);
|
|
154
|
+
expect(result).not.toBeNull();
|
|
155
|
+
expect(result!.message.attachments).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("returns null for group messages", () => {
|
|
159
|
+
const payload = {
|
|
160
|
+
...validPayload,
|
|
161
|
+
message: { ...validPayload.message, chat: { id: 99001, type: "group" } },
|
|
162
|
+
};
|
|
163
|
+
expect(normalizeTelegramUpdate(payload)).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("returns null for payloads without update_id", () => {
|
|
167
|
+
const { update_id: _, ...rest } = validPayload;
|
|
168
|
+
expect(normalizeTelegramUpdate(rest)).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("returns null for payloads without chat id", () => {
|
|
172
|
+
const payload = {
|
|
173
|
+
update_id: 1,
|
|
174
|
+
message: { message_id: 1, text: "hello", chat: {} },
|
|
175
|
+
};
|
|
176
|
+
expect(normalizeTelegramUpdate(payload)).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("uses chat.id as fallback for sender when from.id is missing", () => {
|
|
180
|
+
const payload = {
|
|
181
|
+
update_id: 1,
|
|
182
|
+
message: {
|
|
183
|
+
message_id: 1,
|
|
184
|
+
text: "hello",
|
|
185
|
+
chat: { id: 12345, type: "private" },
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
const result = normalizeTelegramUpdate(payload);
|
|
189
|
+
expect(result).not.toBeNull();
|
|
190
|
+
expect(result!.sender.externalUserId).toBe("12345");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("returns null for non-message updates (e.g. callback_query)", () => {
|
|
194
|
+
const payload = {
|
|
195
|
+
update_id: 1,
|
|
196
|
+
callback_query: { id: "abc", data: "some_data" },
|
|
197
|
+
};
|
|
198
|
+
expect(normalizeTelegramUpdate(payload)).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("normalizes an edited_message update with isEdit flag", () => {
|
|
202
|
+
const payload = {
|
|
203
|
+
update_id: 200,
|
|
204
|
+
edited_message: {
|
|
205
|
+
message_id: 42,
|
|
206
|
+
text: "Hello bot (edited)",
|
|
207
|
+
chat: { id: 99001, type: "private" },
|
|
208
|
+
from: {
|
|
209
|
+
id: 55001,
|
|
210
|
+
is_bot: false,
|
|
211
|
+
username: "testuser",
|
|
212
|
+
first_name: "Test",
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
const result = normalizeTelegramUpdate(payload);
|
|
217
|
+
expect(result).not.toBeNull();
|
|
218
|
+
expect(result!.message.isEdit).toBe(true);
|
|
219
|
+
expect(result!.message.content).toBe("Hello bot (edited)");
|
|
220
|
+
expect(result!.message.externalChatId).toBe("99001");
|
|
221
|
+
expect(result!.message.externalMessageId).toBe("200");
|
|
222
|
+
expect(result!.source.updateId).toBe("200");
|
|
223
|
+
expect(result!.source.messageId).toBe("42");
|
|
224
|
+
expect(result!.sender.externalUserId).toBe("55001");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("prefers message over edited_message when both are present", () => {
|
|
228
|
+
const payload = {
|
|
229
|
+
update_id: 300,
|
|
230
|
+
message: {
|
|
231
|
+
message_id: 50,
|
|
232
|
+
text: "Original",
|
|
233
|
+
chat: { id: 99001, type: "private" },
|
|
234
|
+
from: { id: 55001, is_bot: false },
|
|
235
|
+
},
|
|
236
|
+
edited_message: {
|
|
237
|
+
message_id: 50,
|
|
238
|
+
text: "Edited",
|
|
239
|
+
chat: { id: 99001, type: "private" },
|
|
240
|
+
from: { id: 55001, is_bot: false },
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
const result = normalizeTelegramUpdate(payload);
|
|
244
|
+
expect(result).not.toBeNull();
|
|
245
|
+
expect(result!.message.isEdit).toBeUndefined();
|
|
246
|
+
expect(result!.message.content).toBe("Original");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("sets isEdit for edited_message with photo", () => {
|
|
250
|
+
const payload = {
|
|
251
|
+
update_id: 400,
|
|
252
|
+
edited_message: {
|
|
253
|
+
message_id: 60,
|
|
254
|
+
chat: { id: 200, type: "private" },
|
|
255
|
+
from: { id: 300, is_bot: false },
|
|
256
|
+
photo: [
|
|
257
|
+
{ file_id: "edited_photo", file_unique_id: "ep1", width: 800, height: 800 },
|
|
258
|
+
],
|
|
259
|
+
caption: "Updated caption",
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
const result = normalizeTelegramUpdate(payload);
|
|
263
|
+
expect(result).not.toBeNull();
|
|
264
|
+
expect(result!.message.isEdit).toBe(true);
|
|
265
|
+
expect(result!.message.content).toBe("Updated caption");
|
|
266
|
+
expect(result!.message.attachments).toHaveLength(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("returns null for edited_message in group chat", () => {
|
|
270
|
+
const payload = {
|
|
271
|
+
update_id: 500,
|
|
272
|
+
edited_message: {
|
|
273
|
+
message_id: 70,
|
|
274
|
+
text: "Edited group msg",
|
|
275
|
+
chat: { id: 99001, type: "group" },
|
|
276
|
+
from: { id: 55001, is_bot: false },
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
expect(normalizeTelegramUpdate(payload)).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("verifyWebhookSecret", () => {
|
|
284
|
+
test("returns true for matching secret", () => {
|
|
285
|
+
const headers = new Headers({ "x-telegram-bot-api-secret-token": "my-secret" });
|
|
286
|
+
expect(verifyWebhookSecret(headers, "my-secret")).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("returns false for mismatched secret", () => {
|
|
290
|
+
const headers = new Headers({ "x-telegram-bot-api-secret-token": "wrong" });
|
|
291
|
+
expect(verifyWebhookSecret(headers, "my-secret")).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("returns false when header is missing", () => {
|
|
295
|
+
const headers = new Headers();
|
|
296
|
+
expect(verifyWebhookSecret(headers, "my-secret")).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("returns false when expected secret is empty", () => {
|
|
300
|
+
const headers = new Headers({ "x-telegram-bot-api-secret-token": "something" });
|
|
301
|
+
expect(verifyWebhookSecret(headers, "")).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, test, expect, afterAll } from "bun:test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Proves that non-Telegram requests return 404 when the gateway runs in its
|
|
5
|
+
* default configuration (proxy disabled). Uses the same routing logic as
|
|
6
|
+
* src/index.ts so that changes to the production routing (e.g. accidentally
|
|
7
|
+
* enabling the proxy by default) will be caught by this test.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PORT = 19830; // ephemeral port for test
|
|
11
|
+
|
|
12
|
+
// Minimal env for loadConfig
|
|
13
|
+
const env: Record<string, string> = {
|
|
14
|
+
TELEGRAM_BOT_TOKEN: "test-tok",
|
|
15
|
+
TELEGRAM_WEBHOOK_SECRET: "wh-sec",
|
|
16
|
+
ASSISTANT_RUNTIME_BASE_URL: "http://localhost:7821",
|
|
17
|
+
GATEWAY_PORT: String(PORT),
|
|
18
|
+
// GATEWAY_RUNTIME_PROXY_ENABLED intentionally unset → defaults to false
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Save and set env
|
|
22
|
+
const saved: Record<string, string | undefined> = {};
|
|
23
|
+
for (const [k, v] of Object.entries(env)) {
|
|
24
|
+
saved[k] = process.env[k];
|
|
25
|
+
process.env[k] = v;
|
|
26
|
+
}
|
|
27
|
+
// Ensure proxy flag is unset
|
|
28
|
+
saved["GATEWAY_RUNTIME_PROXY_ENABLED"] =
|
|
29
|
+
process.env.GATEWAY_RUNTIME_PROXY_ENABLED;
|
|
30
|
+
delete process.env.GATEWAY_RUNTIME_PROXY_ENABLED;
|
|
31
|
+
|
|
32
|
+
// Dynamically import to pick up env
|
|
33
|
+
const { loadConfig } = await import("../config.js");
|
|
34
|
+
const { createTelegramWebhookHandler } = await import(
|
|
35
|
+
"../http/routes/telegram-webhook.js"
|
|
36
|
+
);
|
|
37
|
+
const { createRuntimeProxyHandler } = await import(
|
|
38
|
+
"../http/routes/runtime-proxy.js"
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const config = loadConfig();
|
|
42
|
+
|
|
43
|
+
const handleTelegramWebhook = createTelegramWebhookHandler(
|
|
44
|
+
config,
|
|
45
|
+
async () => {},
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Mirror production routing from src/index.ts: only create proxy when enabled
|
|
49
|
+
const handleRuntimeProxy = config.runtimeProxyEnabled
|
|
50
|
+
? createRuntimeProxyHandler(config)
|
|
51
|
+
: null;
|
|
52
|
+
|
|
53
|
+
const server = Bun.serve({
|
|
54
|
+
port: PORT,
|
|
55
|
+
async fetch(req) {
|
|
56
|
+
const url = new URL(req.url);
|
|
57
|
+
|
|
58
|
+
if (url.pathname === "/healthz") {
|
|
59
|
+
return Response.json({ status: "ok" });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (url.pathname === "/readyz") {
|
|
63
|
+
return Response.json({ status: "ok" });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (url.pathname === "/webhooks/telegram") {
|
|
67
|
+
return handleTelegramWebhook(req);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (handleRuntimeProxy) {
|
|
71
|
+
return handleRuntimeProxy(req);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterAll(() => {
|
|
79
|
+
server.stop(true);
|
|
80
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
81
|
+
if (v === undefined) delete process.env[k];
|
|
82
|
+
else process.env[k] = v;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("Telegram-only default: non-Telegram requests return 404", () => {
|
|
87
|
+
test("GET / returns 404", async () => {
|
|
88
|
+
const res = await fetch(`http://localhost:${PORT}/`);
|
|
89
|
+
expect(res.status).toBe(404);
|
|
90
|
+
const body = await res.json();
|
|
91
|
+
expect(body.error).toBe("Not found");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("GET /v1/health returns 404", async () => {
|
|
95
|
+
const res = await fetch(`http://localhost:${PORT}/v1/health`);
|
|
96
|
+
expect(res.status).toBe(404);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("POST /v1/assistants/foo/chat returns 404", async () => {
|
|
100
|
+
const res = await fetch(`http://localhost:${PORT}/v1/assistants/foo/chat`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
body: "{}",
|
|
103
|
+
headers: { "content-type": "application/json" },
|
|
104
|
+
});
|
|
105
|
+
expect(res.status).toBe(404);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("GET /random-path returns 404", async () => {
|
|
109
|
+
const res = await fetch(`http://localhost:${PORT}/random-path`);
|
|
110
|
+
expect(res.status).toBe(404);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("config.runtimeProxyEnabled is false by default", () => {
|
|
114
|
+
expect(config.runtimeProxyEnabled).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("runtime proxy handler is not created when proxy is disabled", () => {
|
|
118
|
+
expect(handleRuntimeProxy).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("GET /healthz returns 200 (infrastructure routes still work)", async () => {
|
|
122
|
+
const res = await fetch(`http://localhost:${PORT}/healthz`);
|
|
123
|
+
expect(res.status).toBe(200);
|
|
124
|
+
const body = await res.json();
|
|
125
|
+
expect(body.status).toBe("ok");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("GET /readyz returns 200 (infrastructure routes still work)", async () => {
|
|
129
|
+
const res = await fetch(`http://localhost:${PORT}/readyz`);
|
|
130
|
+
expect(res.status).toBe(200);
|
|
131
|
+
const body = await res.json();
|
|
132
|
+
expect(body.status).toBe("ok");
|
|
133
|
+
});
|
|
134
|
+
});
|