@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.
- package/api.ts +3 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +1 -0
- package/index.ts +11 -12
- package/openclaw.plugin.json +35 -1
- package/package.json +20 -5
- package/setup-api.ts +1 -0
- package/setup-entry.ts +9 -0
- package/src/accounts.ts +107 -43
- package/src/approval-auth.test.ts +17 -0
- package/src/approval-auth.ts +22 -0
- package/src/channel.integration.test.ts +191 -0
- package/src/channel.test-mocks.ts +176 -0
- package/src/channel.test.ts +471 -174
- package/src/channel.ts +327 -278
- package/src/client.test.ts +296 -44
- package/src/client.ts +217 -24
- package/src/config-schema.ts +11 -0
- package/src/core.test.ts +373 -0
- package/src/gateway-runtime.ts +212 -0
- package/src/inbound-context.ts +10 -0
- package/src/inbound-turn.ts +171 -0
- package/src/runtime.ts +8 -20
- package/src/security-audit.test.ts +66 -0
- package/src/security-audit.ts +28 -0
- package/src/security.ts +67 -53
- package/src/session-key.ts +21 -0
- package/src/setup-surface.ts +338 -0
- package/src/test-http-utils.ts +75 -0
- package/src/types.ts +13 -14
- package/src/webhook-handler.test.ts +418 -75
- package/src/webhook-handler.ts +565 -138
- package/tsconfig.json +16 -0
- package/src/accounts.test.ts +0 -133
- package/src/security.test.ts +0 -98
|
@@ -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 {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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:
|
|
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("
|
|
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
|
-
|
|
135
|
-
|
|
277
|
+
accountId: "weak-token-bruteforce-" + Date.now(),
|
|
278
|
+
token: weakToken,
|
|
279
|
+
rateLimitPerMinute: 5,
|
|
136
280
|
}),
|
|
137
|
-
deliver
|
|
281
|
+
deliver,
|
|
138
282
|
log,
|
|
139
283
|
});
|
|
140
284
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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(
|
|
146
|
-
expect(
|
|
331
|
+
expect(lockedRes._status).toBe(429);
|
|
332
|
+
expect(deliver).not.toHaveBeenCalled();
|
|
147
333
|
});
|
|
148
334
|
|
|
149
|
-
it("
|
|
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({
|
|
152
|
-
|
|
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
|
|
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(
|
|
161
|
-
expect(
|
|
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(
|
|
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(
|
|
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
|
|
214
|
-
const deliver =
|
|
215
|
-
|
|
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
|
});
|