@openclaw/synology-chat 2026.2.22 → 2026.5.2-beta.1

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.
@@ -1,13 +1,20 @@
1
- import { EventEmitter } from "node:events";
2
- import type { IncomingMessage, ServerResponse } from "node:http";
3
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { makeFormBody, makeReq, makeRes, makeStalledReq } from "./test-http-utils.js";
4
3
  import type { ResolvedSynologyChatAccount } from "./types.js";
5
- import { createWebhookHandler } from "./webhook-handler.js";
6
-
7
- // Mock sendMessage to prevent real HTTP calls
8
- vi.mock("./client.js", () => ({
9
- sendMessage: vi.fn().mockResolvedValue(true),
10
- }));
4
+ import type { WebhookHandlerDeps } from "./webhook-handler.js";
5
+ const clientModule = await import("./client.js");
6
+ const sendMessage = vi.spyOn(clientModule, "sendMessage").mockResolvedValue(true);
7
+ const resolveLegacyWebhookNameToChatUserId = vi
8
+ .spyOn(clientModule, "resolveLegacyWebhookNameToChatUserId")
9
+ .mockResolvedValue(undefined);
10
+ const { clearSynologyWebhookRateLimiterStateForTest, createWebhookHandler } =
11
+ await import("./webhook-handler.js");
12
+
13
+ type TestLog = {
14
+ info: (...args: unknown[]) => void;
15
+ warn: (...args: unknown[]) => void;
16
+ error: (...args: unknown[]) => void;
17
+ };
11
18
 
12
19
  function makeAccount(
13
20
  overrides: Partial<ResolvedSynologyChatAccount> = {},
@@ -19,8 +26,11 @@ function makeAccount(
19
26
  incomingUrl: "https://nas.example.com/incoming",
20
27
  nasHost: "nas.example.com",
21
28
  webhookPath: "/webhook/synology",
29
+ webhookPathSource: "default",
30
+ dangerouslyAllowNameMatching: false,
31
+ dangerouslyAllowInheritedWebhookPath: false,
22
32
  dmPolicy: "open",
23
- allowedUserIds: [],
33
+ allowedUserIds: ["*"],
24
34
  rateLimitPerMinute: 30,
25
35
  botName: "TestBot",
26
36
  allowInsecureSsl: true,
@@ -28,40 +38,6 @@ function makeAccount(
28
38
  };
29
39
  }
30
40
 
31
- function makeReq(method: string, body: string): IncomingMessage {
32
- const req = new EventEmitter() as IncomingMessage;
33
- req.method = method;
34
- req.socket = { remoteAddress: "127.0.0.1" } as any;
35
-
36
- // Simulate body delivery
37
- process.nextTick(() => {
38
- req.emit("data", Buffer.from(body));
39
- req.emit("end");
40
- });
41
-
42
- return req;
43
- }
44
-
45
- function makeRes(): ServerResponse & { _status: number; _body: string } {
46
- const res = {
47
- _status: 0,
48
- _body: "",
49
- writeHead(statusCode: number, _headers: Record<string, string>) {
50
- res._status = statusCode;
51
- },
52
- end(body?: string) {
53
- res._body = body ?? "";
54
- },
55
- } as any;
56
- return res;
57
- }
58
-
59
- function makeFormBody(fields: Record<string, string>): string {
60
- return Object.entries(fields)
61
- .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
62
- .join("&");
63
- }
64
-
65
41
  const validBody = makeFormBody({
66
42
  token: "valid-token",
67
43
  user_id: "123",
@@ -69,10 +45,48 @@ const validBody = makeFormBody({
69
45
  text: "Hello bot",
70
46
  });
71
47
 
48
+ async function runDangerousNameMatchReply(
49
+ log: TestLog,
50
+ options: {
51
+ resolvedChatUserId?: number;
52
+ accountIdSuffix: string;
53
+ },
54
+ ) {
55
+ vi.mocked(resolveLegacyWebhookNameToChatUserId).mockResolvedValueOnce(options.resolvedChatUserId);
56
+ const deliver = vi.fn().mockResolvedValue("Bot reply");
57
+ const handler = createWebhookHandler({
58
+ account: makeAccount({
59
+ accountId: `${options.accountIdSuffix}-${Date.now()}`,
60
+ dangerouslyAllowNameMatching: true,
61
+ }),
62
+ deliver,
63
+ log,
64
+ });
65
+
66
+ const req = makeReq("POST", validBody);
67
+ const res = makeRes();
68
+ await handler(req, res);
69
+
70
+ expect(res._status).toBe(204);
71
+ expect(resolveLegacyWebhookNameToChatUserId).toHaveBeenCalledWith({
72
+ incomingUrl: "https://nas.example.com/incoming",
73
+ mutableWebhookUsername: "testuser",
74
+ allowInsecureSsl: true,
75
+ log,
76
+ });
77
+
78
+ return { deliver };
79
+ }
80
+
72
81
  describe("createWebhookHandler", () => {
73
- let log: { info: any; warn: any; error: any };
82
+ let log: TestLog;
74
83
 
75
84
  beforeEach(() => {
85
+ clearSynologyWebhookRateLimiterStateForTest();
86
+ sendMessage.mockClear();
87
+ sendMessage.mockResolvedValue(true);
88
+ resolveLegacyWebhookNameToChatUserId.mockClear();
89
+ resolveLegacyWebhookNameToChatUserId.mockResolvedValue(undefined);
76
90
  log = {
77
91
  info: vi.fn(),
78
92
  warn: vi.fn(),
@@ -80,6 +94,90 @@ describe("createWebhookHandler", () => {
80
94
  };
81
95
  });
82
96
 
97
+ async function expectForbiddenByPolicy(params: {
98
+ account: Partial<ResolvedSynologyChatAccount>;
99
+ bodyContains: string;
100
+ deliver?: WebhookHandlerDeps["deliver"];
101
+ }) {
102
+ const deliver = params.deliver ?? vi.fn();
103
+ const handler = createWebhookHandler({
104
+ account: makeAccount(params.account),
105
+ deliver,
106
+ log,
107
+ });
108
+
109
+ const req = makeReq("POST", validBody);
110
+ const res = makeRes();
111
+ await handler(req, res);
112
+
113
+ expect(res._status).toBe(403);
114
+ expect(res._body).toContain(params.bodyContains);
115
+ expect(deliver).not.toHaveBeenCalled();
116
+ }
117
+
118
+ function makeTestHandler(params: {
119
+ accountIdSuffix: string;
120
+ deliver?: WebhookHandlerDeps["deliver"];
121
+ account?: Partial<ResolvedSynologyChatAccount>;
122
+ }) {
123
+ const deliver = params.deliver ?? vi.fn().mockResolvedValue(null);
124
+ return {
125
+ deliver,
126
+ handler: createWebhookHandler({
127
+ account: makeAccount({
128
+ accountId: `${params.accountIdSuffix}-${Date.now()}`,
129
+ ...params.account,
130
+ }),
131
+ deliver,
132
+ log,
133
+ }),
134
+ };
135
+ }
136
+
137
+ async function postToWebhook(
138
+ handler: ReturnType<typeof createWebhookHandler>,
139
+ body = validBody,
140
+ options?: Parameters<typeof makeReq>[2],
141
+ ) {
142
+ const req = makeReq("POST", body, options);
143
+ const res = makeRes();
144
+ await handler(req, res);
145
+ return res;
146
+ }
147
+
148
+ async function expectTokenlessBodyAccepted(params: {
149
+ accountIdSuffix: string;
150
+ options: Parameters<typeof makeReq>[2];
151
+ }) {
152
+ const { deliver, handler } = makeTestHandler({ accountIdSuffix: params.accountIdSuffix });
153
+ const res = await postToWebhook(
154
+ handler,
155
+ makeFormBody({ user_id: "123", username: "testuser", text: "hello" }),
156
+ params.options,
157
+ );
158
+ expect(res._status).toBe(204);
159
+ expect(deliver).toHaveBeenCalled();
160
+ }
161
+
162
+ async function runValidReply(params: { accountIdSuffix: string; reply?: string }) {
163
+ const { deliver, handler } = makeTestHandler({
164
+ accountIdSuffix: params.accountIdSuffix,
165
+ deliver: vi.fn().mockResolvedValue(params.reply ?? "Bot reply"),
166
+ });
167
+ const res = await postToWebhook(handler);
168
+ expect(res._status).toBe(204);
169
+ return { deliver, res };
170
+ }
171
+
172
+ function expectBotReplySentTo(chatUserId: string) {
173
+ expect(sendMessage).toHaveBeenCalledWith(
174
+ "https://nas.example.com/incoming",
175
+ "Bot reply",
176
+ chatUserId,
177
+ true,
178
+ );
179
+ }
180
+
83
181
  it("rejects non-POST methods with 405", async () => {
84
182
  const handler = createWebhookHandler({
85
183
  account: makeAccount(),
@@ -108,6 +206,49 @@ describe("createWebhookHandler", () => {
108
206
  expect(res._status).toBe(400);
109
207
  });
110
208
 
209
+ it("returns 408 when request body times out", async () => {
210
+ const handler = createWebhookHandler({
211
+ account: makeAccount(),
212
+ deliver: vi.fn(),
213
+ log,
214
+ bodyTimeoutMs: 1,
215
+ });
216
+
217
+ const req = makeStalledReq("POST");
218
+ const res = makeRes();
219
+ await handler(req, res);
220
+
221
+ expect(res._status).toBe(408);
222
+ expect(res._body).toContain("timeout");
223
+ });
224
+
225
+ it("rejects excess concurrent pre-auth body reads from the same remote IP", async () => {
226
+ const handler = createWebhookHandler({
227
+ account: makeAccount({ accountId: "preauth-inflight-test-" + Date.now() }),
228
+ deliver: vi.fn(),
229
+ log,
230
+ });
231
+
232
+ const requests = Array.from({ length: 12 }, () => {
233
+ const req = makeStalledReq("POST");
234
+ (req.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.10";
235
+ return req;
236
+ });
237
+ const responses = requests.map(() => makeRes());
238
+ const runs = requests.map((req, index) => handler(req, responses[index]));
239
+
240
+ await new Promise((resolve) => setTimeout(resolve, 0));
241
+
242
+ // Default maxInFlightPerKey is 8; 12 total requests leaves 4 rejected with 429.
243
+ expect(responses.filter((res) => res._status === 0)).toHaveLength(8);
244
+ expect(responses.filter((res) => res._status === 429)).toHaveLength(4);
245
+
246
+ for (const req of requests) {
247
+ req.emit("end");
248
+ }
249
+ await Promise.all(runs);
250
+ });
251
+
111
252
  it("returns 401 for invalid token", async () => {
112
253
  const handler = createWebhookHandler({
113
254
  account: makeAccount(),
@@ -128,37 +269,206 @@ describe("createWebhookHandler", () => {
128
269
  expect(res._status).toBe(401);
129
270
  });
130
271
 
131
- it("returns 403 for unauthorized user with allowlist policy", async () => {
272
+ it("rate limits repeated invalid token guesses before the correct token can succeed", async () => {
273
+ const weakToken = "00000129";
274
+ const deliver = vi.fn().mockResolvedValue(null);
132
275
  const handler = createWebhookHandler({
133
276
  account: makeAccount({
134
- dmPolicy: "allowlist",
135
- allowedUserIds: ["456"],
277
+ accountId: "weak-token-bruteforce-" + Date.now(),
278
+ token: weakToken,
279
+ rateLimitPerMinute: 5,
136
280
  }),
137
- deliver: vi.fn(),
281
+ deliver,
138
282
  log,
139
283
  });
140
284
 
141
- const req = makeReq("POST", validBody);
142
- const res = makeRes();
143
- await handler(req, res);
285
+ let guessedToken: string | null = null;
286
+ let saw429 = false;
287
+
288
+ for (let i = 0; i < 130; i += 1) {
289
+ const candidate = String(i).padStart(8, "0");
290
+ const req = makeReq(
291
+ "POST",
292
+ makeFormBody({
293
+ token: candidate,
294
+ user_id: "123",
295
+ username: "testuser",
296
+ text: "Hello bot",
297
+ }),
298
+ );
299
+ (req.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.10";
300
+ const res = makeRes();
301
+ await handler(req, res);
302
+
303
+ if (res._status === 429) {
304
+ saw429 = true;
305
+ break;
306
+ }
307
+
308
+ if (res._status === 204) {
309
+ guessedToken = candidate;
310
+ break;
311
+ }
312
+
313
+ expect(res._status).toBe(401);
314
+ }
315
+
316
+ expect(saw429).toBe(true);
317
+ expect(guessedToken).toBeNull();
318
+ const lockedReq = makeReq(
319
+ "POST",
320
+ makeFormBody({
321
+ token: weakToken,
322
+ user_id: "123",
323
+ username: "testuser",
324
+ text: "Hello bot",
325
+ }),
326
+ );
327
+ (lockedReq.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.10";
328
+ const lockedRes = makeRes();
329
+ await handler(lockedReq, lockedRes);
144
330
 
145
- expect(res._status).toBe(403);
146
- expect(res._body).toContain("not authorized");
331
+ expect(lockedRes._status).toBe(429);
332
+ expect(deliver).not.toHaveBeenCalled();
147
333
  });
148
334
 
149
- it("returns 403 when DMs are disabled", async () => {
335
+ it("keeps pre-auth throttling scoped to the remote IP", async () => {
336
+ const deliver = vi.fn().mockResolvedValue(null);
150
337
  const handler = createWebhookHandler({
151
- account: makeAccount({ dmPolicy: "disabled" }),
152
- deliver: vi.fn(),
338
+ account: makeAccount({
339
+ accountId: "preauth-ip-scope-" + Date.now(),
340
+ rateLimitPerMinute: 1,
341
+ }),
342
+ deliver,
153
343
  log,
154
344
  });
155
345
 
156
- const req = makeReq("POST", validBody);
346
+ const invalidReq = makeReq(
347
+ "POST",
348
+ makeFormBody({
349
+ token: "wrong-token",
350
+ user_id: "123",
351
+ username: "testuser",
352
+ text: "Hello",
353
+ }),
354
+ );
355
+ (invalidReq.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.10";
356
+ const invalidRes = makeRes();
357
+ await handler(invalidReq, invalidRes);
358
+ expect(invalidRes._status).toBe(401);
359
+
360
+ const validReq = makeReq("POST", validBody);
361
+ (validReq.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.11";
362
+ const validRes = makeRes();
363
+ await handler(validReq, validRes);
364
+
365
+ expect(validRes._status).toBe(204);
366
+ expect(deliver).toHaveBeenCalledTimes(1);
367
+ });
368
+
369
+ it("does not spend invalid-token budget on successful requests", async () => {
370
+ const deliver = vi.fn().mockResolvedValue(null);
371
+ const handler = createWebhookHandler({
372
+ account: makeAccount({
373
+ accountId: "invalid-token-budget-" + Date.now(),
374
+ rateLimitPerMinute: 30,
375
+ }),
376
+ deliver,
377
+ log,
378
+ });
379
+
380
+ for (let i = 0; i < 11; i += 1) {
381
+ const req = makeReq("POST", validBody);
382
+ (req.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.20";
383
+ const res = makeRes();
384
+ await handler(req, res);
385
+ expect(res._status).toBe(204);
386
+ }
387
+
388
+ expect(deliver).toHaveBeenCalledTimes(11);
389
+ });
390
+
391
+ it("accepts application/json with alias fields", async () => {
392
+ const deliver = vi.fn().mockResolvedValue(null);
393
+ const handler = createWebhookHandler({
394
+ account: makeAccount({ accountId: "json-test-" + Date.now() }),
395
+ deliver,
396
+ log,
397
+ });
398
+
399
+ const req = makeReq(
400
+ "POST",
401
+ JSON.stringify({
402
+ token: "valid-token",
403
+ userId: "123",
404
+ name: "json-user",
405
+ message: "Hello from json",
406
+ }),
407
+ { headers: { "content-type": "application/json" } },
408
+ );
157
409
  const res = makeRes();
158
410
  await handler(req, res);
159
411
 
160
- expect(res._status).toBe(403);
161
- expect(res._body).toContain("disabled");
412
+ expect(res._status).toBe(204);
413
+ expect(deliver).toHaveBeenCalledWith(
414
+ expect.objectContaining({
415
+ body: "Hello from json",
416
+ from: "123",
417
+ senderName: "json-user",
418
+ commandAuthorized: true,
419
+ }),
420
+ );
421
+ });
422
+
423
+ it("accepts token from query when body token is absent", async () => {
424
+ await expectTokenlessBodyAccepted({
425
+ accountIdSuffix: "query-token-test",
426
+ options: {
427
+ headers: { "content-type": "application/x-www-form-urlencoded" },
428
+ url: "/webhook/synology?token=valid-token",
429
+ },
430
+ });
431
+ });
432
+
433
+ it("accepts token from authorization header when body token is absent", async () => {
434
+ await expectTokenlessBodyAccepted({
435
+ accountIdSuffix: "header-token-test",
436
+ options: {
437
+ headers: {
438
+ "content-type": "application/x-www-form-urlencoded",
439
+ authorization: "Bearer valid-token",
440
+ },
441
+ },
442
+ });
443
+ });
444
+
445
+ it("returns 403 for unauthorized user with allowlist policy", async () => {
446
+ await expectForbiddenByPolicy({
447
+ account: {
448
+ dmPolicy: "allowlist",
449
+ allowedUserIds: ["456"],
450
+ },
451
+ bodyContains: "not authorized",
452
+ });
453
+ });
454
+
455
+ it("returns 403 when allowlist policy is set with empty allowedUserIds", async () => {
456
+ const deliver = vi.fn();
457
+ await expectForbiddenByPolicy({
458
+ account: {
459
+ dmPolicy: "allowlist",
460
+ allowedUserIds: [],
461
+ },
462
+ bodyContains: "Allowlist is empty",
463
+ deliver,
464
+ });
465
+ });
466
+
467
+ it("returns 403 when DMs are disabled", async () => {
468
+ await expectForbiddenByPolicy({
469
+ account: { dmPolicy: "disabled" },
470
+ bodyContains: "disabled",
471
+ });
162
472
  });
163
473
 
164
474
  it("returns 429 when rate limited", async () => {
@@ -176,7 +486,7 @@ describe("createWebhookHandler", () => {
176
486
  const req1 = makeReq("POST", validBody);
177
487
  const res1 = makeRes();
178
488
  await handler(req1, res1);
179
- expect(res1._status).toBe(200);
489
+ expect(res1._status).toBe(204);
180
490
 
181
491
  // Second request should be rate limited
182
492
  const req2 = makeReq("POST", validBody);
@@ -205,25 +515,14 @@ describe("createWebhookHandler", () => {
205
515
  const res = makeRes();
206
516
  await handler(req, res);
207
517
 
208
- expect(res._status).toBe(200);
518
+ expect(res._status).toBe(204);
209
519
  // deliver should have been called with the stripped text
210
520
  expect(deliver).toHaveBeenCalledWith(expect.objectContaining({ body: "Hello there" }));
211
521
  });
212
522
 
213
- it("responds 200 immediately and delivers async", async () => {
214
- const deliver = vi.fn().mockResolvedValue("Bot reply");
215
- const handler = createWebhookHandler({
216
- account: makeAccount({ accountId: "async-test-" + Date.now() }),
217
- deliver,
218
- log,
219
- });
220
-
221
- const req = makeReq("POST", validBody);
222
- const res = makeRes();
223
- await handler(req, res);
224
-
225
- expect(res._status).toBe(200);
226
- expect(res._body).toContain("Processing");
523
+ it("responds 204 immediately and delivers async", async () => {
524
+ const { deliver, res } = await runValidReply({ accountIdSuffix: "async-test" });
525
+ expect(res._body).toBe("");
227
526
  expect(deliver).toHaveBeenCalledWith(
228
527
  expect.objectContaining({
229
528
  body: "Hello bot",
@@ -231,8 +530,51 @@ describe("createWebhookHandler", () => {
231
530
  senderName: "testuser",
232
531
  provider: "synology-chat",
233
532
  chatType: "direct",
533
+ commandAuthorized: true,
534
+ }),
535
+ );
536
+ });
537
+
538
+ it("keeps replies bound to payload.user_id by default", async () => {
539
+ const { deliver } = await runValidReply({ accountIdSuffix: "stable-id-test" });
540
+ expect(resolveLegacyWebhookNameToChatUserId).not.toHaveBeenCalled();
541
+ expect(deliver).toHaveBeenCalledWith(
542
+ expect.objectContaining({
543
+ from: "123",
544
+ chatUserId: "123",
545
+ }),
546
+ );
547
+ expectBotReplySentTo("123");
548
+ });
549
+
550
+ it("only resolves reply recipient by username when break-glass mode is enabled", async () => {
551
+ const { deliver } = await runDangerousNameMatchReply(log, {
552
+ resolvedChatUserId: 456,
553
+ accountIdSuffix: "dangerous-name-match-test",
554
+ });
555
+ expect(deliver).toHaveBeenCalledWith(
556
+ expect.objectContaining({
557
+ from: "123",
558
+ chatUserId: "456",
559
+ }),
560
+ );
561
+ expectBotReplySentTo("456");
562
+ });
563
+
564
+ it("falls back to payload.user_id when break-glass resolution does not find a match", async () => {
565
+ const { deliver } = await runDangerousNameMatchReply(log, {
566
+ accountIdSuffix: "dangerous-name-fallback-test",
567
+ });
568
+ expect(log.warn).toHaveBeenCalledWith(
569
+ 'Could not resolve Chat API user_id for "testuser" — falling back to webhook user_id 123. Reply delivery may fail.',
570
+ );
571
+ expect(deliver).toHaveBeenCalledWith(
572
+ expect.objectContaining({
573
+ from: "123",
574
+ chatUserId: "123",
234
575
  }),
235
576
  );
577
+ expectBotReplySentTo("123");
236
578
  });
237
579
 
238
580
  it("sanitizes input before delivery", async () => {
@@ -257,6 +599,7 @@ describe("createWebhookHandler", () => {
257
599
  expect(deliver).toHaveBeenCalledWith(
258
600
  expect.objectContaining({
259
601
  body: expect.stringContaining("[FILTERED]"),
602
+ commandAuthorized: true,
260
603
  }),
261
604
  );
262
605
  });