@openclaw/zalo 2026.5.2 → 2026.5.3-beta.2

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.
Files changed (87) hide show
  1. package/dist/accounts-9NLDDlZ8.js +118 -0
  2. package/dist/actions.runtime-kJ65ZxW7.js +5 -0
  3. package/dist/api.js +5 -0
  4. package/dist/channel-VPbtV3Oq.js +343 -0
  5. package/dist/channel-plugin-api.js +2 -0
  6. package/dist/channel.runtime-BnTAWQx5.js +106 -0
  7. package/dist/contract-api.js +3 -0
  8. package/dist/group-access-DZR43lOR.js +30 -0
  9. package/dist/index.js +22 -0
  10. package/dist/monitor-DMysJBWa.js +823 -0
  11. package/dist/monitor.webhook-DqnuvgjV.js +175 -0
  12. package/dist/proxy-CY8VuC6H.js +135 -0
  13. package/dist/runtime-BRFxnYQx.js +8 -0
  14. package/dist/runtime-api-MOTmRW4F.js +19 -0
  15. package/dist/runtime-api.js +3 -0
  16. package/dist/secret-contract-Dw93tGo2.js +87 -0
  17. package/dist/secret-contract-api.js +2 -0
  18. package/dist/send-Gv3l5EGI.js +101 -0
  19. package/dist/setup-api.js +30 -0
  20. package/dist/setup-core-DigRD3j1.js +166 -0
  21. package/dist/setup-entry.js +15 -0
  22. package/dist/setup-surface-2Up3yWov.js +216 -0
  23. package/dist/test-api.js +2 -0
  24. package/package.json +15 -6
  25. package/api.ts +0 -9
  26. package/channel-plugin-api.ts +0 -1
  27. package/contract-api.ts +0 -5
  28. package/index.test.ts +0 -15
  29. package/index.ts +0 -20
  30. package/runtime-api.test.ts +0 -17
  31. package/runtime-api.ts +0 -75
  32. package/secret-contract-api.ts +0 -5
  33. package/setup-api.ts +0 -34
  34. package/setup-entry.ts +0 -13
  35. package/src/accounts.test.ts +0 -70
  36. package/src/accounts.ts +0 -60
  37. package/src/actions.runtime.ts +0 -5
  38. package/src/actions.test.ts +0 -32
  39. package/src/actions.ts +0 -62
  40. package/src/api.test.ts +0 -149
  41. package/src/api.ts +0 -265
  42. package/src/approval-auth.test.ts +0 -17
  43. package/src/approval-auth.ts +0 -25
  44. package/src/channel.directory.test.ts +0 -59
  45. package/src/channel.runtime.ts +0 -93
  46. package/src/channel.startup.test.ts +0 -101
  47. package/src/channel.ts +0 -275
  48. package/src/config-schema.test.ts +0 -30
  49. package/src/config-schema.ts +0 -29
  50. package/src/group-access.ts +0 -49
  51. package/src/monitor.group-policy.test.ts +0 -94
  52. package/src/monitor.image.polling.test.ts +0 -110
  53. package/src/monitor.lifecycle.test.ts +0 -198
  54. package/src/monitor.pairing.lifecycle.test.ts +0 -141
  55. package/src/monitor.polling.media-reply.test.ts +0 -425
  56. package/src/monitor.reply-once.lifecycle.test.ts +0 -171
  57. package/src/monitor.ts +0 -1028
  58. package/src/monitor.types.ts +0 -4
  59. package/src/monitor.webhook.test.ts +0 -806
  60. package/src/monitor.webhook.ts +0 -278
  61. package/src/outbound-media.test.ts +0 -182
  62. package/src/outbound-media.ts +0 -241
  63. package/src/outbound-payload.contract.test.ts +0 -45
  64. package/src/probe.ts +0 -45
  65. package/src/proxy.ts +0 -24
  66. package/src/runtime-api.ts +0 -75
  67. package/src/runtime-support.ts +0 -91
  68. package/src/runtime.ts +0 -9
  69. package/src/secret-contract.ts +0 -109
  70. package/src/secret-input.ts +0 -5
  71. package/src/send.test.ts +0 -120
  72. package/src/send.ts +0 -153
  73. package/src/session-route.ts +0 -32
  74. package/src/setup-allow-from.ts +0 -94
  75. package/src/setup-core.ts +0 -149
  76. package/src/setup-status.test.ts +0 -33
  77. package/src/setup-surface.test.ts +0 -175
  78. package/src/setup-surface.ts +0 -291
  79. package/src/status-issues.test.ts +0 -17
  80. package/src/status-issues.ts +0 -37
  81. package/src/test-support/lifecycle-test-support.ts +0 -413
  82. package/src/test-support/monitor-mocks-test-support.ts +0 -209
  83. package/src/token.test.ts +0 -92
  84. package/src/token.ts +0 -79
  85. package/src/types.ts +0 -50
  86. package/test-api.ts +0 -1
  87. package/tsconfig.json +0 -16
@@ -1,806 +0,0 @@
1
- import type { RequestListener } from "node:http";
2
- import {
3
- createEmptyPluginRegistry,
4
- setActivePluginRegistry,
5
- } from "openclaw/plugin-sdk/plugin-test-runtime";
6
- import { withServer } from "openclaw/plugin-sdk/test-env";
7
- import { afterEach, describe, expect, it, vi } from "vitest";
8
- import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
9
- import { handleZaloWebhookRequest } from "./monitor.js";
10
- import type { ZaloRuntimeEnv } from "./monitor.types.js";
11
- import {
12
- clearZaloWebhookSecurityStateForTest,
13
- getZaloWebhookRateLimitStateSizeForTest,
14
- getZaloWebhookStatusCounterSizeForTest,
15
- handleZaloWebhookRequest as handleZaloWebhookRequestInternal,
16
- registerZaloWebhookTarget,
17
- type ZaloWebhookProcessUpdate,
18
- ZaloRetryableWebhookError,
19
- } from "./monitor.webhook.js";
20
- import {
21
- createImageLifecycleCore,
22
- createImageUpdate,
23
- createTextUpdate,
24
- expectImageLifecycleDelivery,
25
- postWebhookReplay,
26
- } from "./test-support/lifecycle-test-support.js";
27
- import type { ResolvedZaloAccount } from "./types.js";
28
- const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
29
- accountId: "default",
30
- enabled: true,
31
- token: "tok",
32
- tokenSource: "config",
33
- config: {},
34
- };
35
-
36
- function createWebhookRequestHandler(processUpdate?: ZaloWebhookProcessUpdate): RequestListener {
37
- return async (req, res) => {
38
- const handled = processUpdate
39
- ? await handleZaloWebhookRequestInternal(req, res, processUpdate)
40
- : await handleZaloWebhookRequest(req, res);
41
- if (!handled) {
42
- res.statusCode = 404;
43
- res.end("not found");
44
- }
45
- };
46
- }
47
-
48
- const webhookRequestHandler = createWebhookRequestHandler();
49
-
50
- function registerTarget(params: {
51
- path: string;
52
- secret?: string;
53
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
54
- account?: ResolvedZaloAccount;
55
- config?: OpenClawConfig;
56
- core?: PluginRuntime;
57
- runtime?: Partial<ZaloRuntimeEnv>;
58
- }): () => void {
59
- return registerZaloWebhookTarget({
60
- token: "tok",
61
- account: params.account ?? DEFAULT_ACCOUNT,
62
- config: params.config ?? ({} as OpenClawConfig),
63
- runtime: (params.runtime ?? {}) as ZaloRuntimeEnv,
64
- core: params.core ?? ({} as PluginRuntime),
65
- secret: params.secret ?? "secret",
66
- path: params.path,
67
- webhookUrl: `https://example.com${params.path}`,
68
- webhookPath: params.path,
69
- mediaMaxMb: 5,
70
- canHostMedia: true,
71
- statusSink: params.statusSink,
72
- });
73
- }
74
-
75
- function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCreated?: boolean }): {
76
- core: PluginRuntime;
77
- readAllowFromStore: ReturnType<typeof vi.fn>;
78
- upsertPairingRequest: ReturnType<typeof vi.fn>;
79
- } {
80
- const readAllowFromStore = vi.fn().mockResolvedValue(params?.storeAllowFrom ?? []);
81
- const upsertPairingRequest = vi
82
- .fn()
83
- .mockResolvedValue({ code: "PAIRCODE", created: params?.pairingCreated ?? false });
84
- const core = {
85
- logging: {
86
- shouldLogVerbose: () => false,
87
- },
88
- channel: {
89
- pairing: {
90
- readAllowFromStore,
91
- upsertPairingRequest,
92
- buildPairingReply: vi.fn(() => "Pairing code: PAIRCODE"),
93
- },
94
- commands: {
95
- shouldComputeCommandAuthorized: vi.fn(() => false),
96
- resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
97
- },
98
- },
99
- } as unknown as PluginRuntime;
100
- return { core, readAllowFromStore, upsertPairingRequest };
101
- }
102
-
103
- async function postUntilRateLimited(params: {
104
- baseUrl: string;
105
- path: string;
106
- secret: string;
107
- withNonceQuery?: boolean;
108
- attempts?: number;
109
- }): Promise<boolean> {
110
- const attempts = params.attempts ?? 130;
111
- for (let i = 0; i < attempts; i += 1) {
112
- const url = params.withNonceQuery
113
- ? `${params.baseUrl}${params.path}?nonce=${i}`
114
- : `${params.baseUrl}${params.path}`;
115
- const response = await fetch(url, {
116
- method: "POST",
117
- headers: {
118
- "x-bot-api-secret-token": params.secret,
119
- "content-type": "application/json",
120
- },
121
- body: "{}",
122
- });
123
- if (response.status === 429) {
124
- return true;
125
- }
126
- }
127
- return false;
128
- }
129
-
130
- async function postWebhookJson(params: {
131
- baseUrl: string;
132
- path: string;
133
- secret: string;
134
- payload: unknown;
135
- }) {
136
- return fetch(`${params.baseUrl}${params.path}`, {
137
- method: "POST",
138
- headers: {
139
- "x-bot-api-secret-token": params.secret,
140
- "content-type": "application/json",
141
- },
142
- body: JSON.stringify(params.payload),
143
- });
144
- }
145
-
146
- async function expectTwoWebhookPostsOk(params: {
147
- baseUrl: string;
148
- first: { path: string; secret: string; payload: unknown };
149
- second: { path: string; secret: string; payload: unknown };
150
- }) {
151
- const first = await postWebhookJson({
152
- baseUrl: params.baseUrl,
153
- path: params.first.path,
154
- secret: params.first.secret,
155
- payload: params.first.payload,
156
- });
157
- const second = await postWebhookJson({
158
- baseUrl: params.baseUrl,
159
- path: params.second.path,
160
- secret: params.second.secret,
161
- payload: params.second.payload,
162
- });
163
-
164
- expect(first.status).toBe(200);
165
- expect(second.status).toBe(200);
166
- }
167
-
168
- describe("handleZaloWebhookRequest", () => {
169
- afterEach(() => {
170
- clearZaloWebhookSecurityStateForTest();
171
- setActivePluginRegistry(createEmptyPluginRegistry());
172
- });
173
-
174
- it("returns 400 for non-object payloads", async () => {
175
- const unregister = registerTarget({ path: "/hook" });
176
-
177
- try {
178
- await withServer(webhookRequestHandler, async (baseUrl) => {
179
- const response = await fetch(`${baseUrl}/hook`, {
180
- method: "POST",
181
- headers: {
182
- "x-bot-api-secret-token": "secret",
183
- "content-type": "application/json",
184
- },
185
- body: "null",
186
- });
187
-
188
- expect(response.status).toBe(400);
189
- expect(await response.text()).toBe("Bad Request");
190
- });
191
- } finally {
192
- unregister();
193
- }
194
- });
195
-
196
- it("rejects ambiguous routing when multiple targets match the same secret", async () => {
197
- const sinkA = vi.fn();
198
- const sinkB = vi.fn();
199
- const unregisterA = registerTarget({ path: "/hook", statusSink: sinkA });
200
- const unregisterB = registerTarget({ path: "/hook", statusSink: sinkB });
201
-
202
- try {
203
- await withServer(webhookRequestHandler, async (baseUrl) => {
204
- const response = await fetch(`${baseUrl}/hook`, {
205
- method: "POST",
206
- headers: {
207
- "x-bot-api-secret-token": "secret",
208
- "content-type": "application/json",
209
- },
210
- body: "{}",
211
- });
212
-
213
- expect(response.status).toBe(401);
214
- expect(sinkA).not.toHaveBeenCalled();
215
- expect(sinkB).not.toHaveBeenCalled();
216
- });
217
- } finally {
218
- unregisterA();
219
- unregisterB();
220
- }
221
- });
222
-
223
- it("returns 415 for non-json content-type", async () => {
224
- const unregister = registerTarget({ path: "/hook-content-type" });
225
-
226
- try {
227
- await withServer(webhookRequestHandler, async (baseUrl) => {
228
- const response = await fetch(`${baseUrl}/hook-content-type`, {
229
- method: "POST",
230
- headers: {
231
- "x-bot-api-secret-token": "secret",
232
- "content-type": "text/plain",
233
- },
234
- body: "{}",
235
- });
236
-
237
- expect(response.status).toBe(415);
238
- });
239
- } finally {
240
- unregister();
241
- }
242
- });
243
-
244
- it("deduplicates webhook replay for the same event origin", async () => {
245
- const sink = vi.fn();
246
- const unregister = registerTarget({ path: "/hook-replay", statusSink: sink });
247
- const payload = createTextUpdate({
248
- messageId: "msg-replay-1",
249
- userId: "123",
250
- userName: "",
251
- chatId: "123",
252
- text: "hello",
253
- });
254
-
255
- try {
256
- await withServer(webhookRequestHandler, async (baseUrl) => {
257
- const { first, replay } = await postWebhookReplay({
258
- baseUrl,
259
- path: "/hook-replay",
260
- secret: "secret",
261
- payload,
262
- });
263
-
264
- expect(first.status).toBe(200);
265
- expect(replay.status).toBe(200);
266
- expect(sink).toHaveBeenCalledTimes(1);
267
- });
268
- } finally {
269
- unregister();
270
- }
271
- });
272
-
273
- it("allows a retry after processUpdate throws a retryable replay error", async () => {
274
- const error = vi.fn();
275
- const unregister = registerTarget({
276
- path: "/hook-retry-after-failure",
277
- runtime: { error },
278
- });
279
- const payload = createTextUpdate({
280
- messageId: "msg-retry-after-failure-1",
281
- userId: "123",
282
- userName: "",
283
- chatId: "123",
284
- text: "hello",
285
- });
286
- let attempts = 0;
287
- const processUpdate = vi.fn<ZaloWebhookProcessUpdate>(async () => {
288
- attempts += 1;
289
- if (attempts === 1) {
290
- throw new ZaloRetryableWebhookError("boom");
291
- }
292
- });
293
-
294
- try {
295
- await withServer(createWebhookRequestHandler(processUpdate), async (baseUrl) => {
296
- const first = await postWebhookJson({
297
- baseUrl,
298
- path: "/hook-retry-after-failure",
299
- secret: "secret",
300
- payload,
301
- });
302
-
303
- expect(first.status).toBe(200);
304
- await vi.waitFor(() => expect(error).toHaveBeenCalledTimes(1));
305
-
306
- const second = await postWebhookJson({
307
- baseUrl,
308
- path: "/hook-retry-after-failure",
309
- secret: "secret",
310
- payload,
311
- });
312
-
313
- expect(second.status).toBe(200);
314
- await vi.waitFor(() => expect(processUpdate).toHaveBeenCalledTimes(2));
315
- });
316
- } finally {
317
- unregister();
318
- }
319
- });
320
-
321
- it("keeps replay dedupe isolated per authenticated target", async () => {
322
- const sinkA = vi.fn();
323
- const sinkB = vi.fn();
324
- const unregisterA = registerTarget({
325
- path: "/hook-replay-scope",
326
- secret: "secret-a",
327
- statusSink: sinkA,
328
- });
329
- const unregisterB = registerTarget({
330
- path: "/hook-replay-scope",
331
- secret: "secret-b",
332
- statusSink: sinkB,
333
- account: {
334
- ...DEFAULT_ACCOUNT,
335
- accountId: "work",
336
- },
337
- });
338
- const payload = createTextUpdate({
339
- messageId: "msg-replay-scope-1",
340
- userId: "123",
341
- userName: "",
342
- chatId: "123",
343
- text: "hello",
344
- });
345
-
346
- try {
347
- await withServer(webhookRequestHandler, async (baseUrl) => {
348
- await expectTwoWebhookPostsOk({
349
- baseUrl,
350
- first: { path: "/hook-replay-scope", secret: "secret-a", payload },
351
- second: { path: "/hook-replay-scope", secret: "secret-b", payload },
352
- });
353
- });
354
-
355
- expect(sinkA).toHaveBeenCalledTimes(1);
356
- expect(sinkB).toHaveBeenCalledTimes(1);
357
- } finally {
358
- unregisterA();
359
- unregisterB();
360
- }
361
- });
362
-
363
- it("does not collide replay dedupe across different chats", async () => {
364
- const sink = vi.fn();
365
- const unregister = registerTarget({ path: "/hook-replay-chat-scope", statusSink: sink });
366
- const firstPayload = createTextUpdate({
367
- messageId: "msg-replay-chat-1",
368
- userId: "123",
369
- userName: "",
370
- chatId: "chat-a",
371
- text: "hello from a",
372
- });
373
- const secondPayload = createTextUpdate({
374
- messageId: "msg-replay-chat-1",
375
- userId: "123",
376
- userName: "",
377
- chatId: "chat-b",
378
- text: "hello from b",
379
- });
380
-
381
- try {
382
- await withServer(webhookRequestHandler, async (baseUrl) => {
383
- await expectTwoWebhookPostsOk({
384
- baseUrl,
385
- first: { path: "/hook-replay-chat-scope", secret: "secret", payload: firstPayload },
386
- second: { path: "/hook-replay-chat-scope", secret: "secret", payload: secondPayload },
387
- });
388
- });
389
-
390
- expect(sink).toHaveBeenCalledTimes(2);
391
- } finally {
392
- unregister();
393
- }
394
- });
395
-
396
- it("does not collide replay dedupe across different senders in the same chat", async () => {
397
- const sink = vi.fn();
398
- const unregister = registerTarget({ path: "/hook-replay-sender-scope", statusSink: sink });
399
- const firstPayload = createTextUpdate({
400
- messageId: "msg-replay-sender-1",
401
- userId: "user-a",
402
- userName: "",
403
- chatId: "chat-shared",
404
- text: "hello from user a",
405
- });
406
- const secondPayload = createTextUpdate({
407
- messageId: "msg-replay-sender-1",
408
- userId: "user-b",
409
- userName: "",
410
- chatId: "chat-shared",
411
- text: "hello from user b",
412
- });
413
-
414
- try {
415
- await withServer(webhookRequestHandler, async (baseUrl) => {
416
- await expectTwoWebhookPostsOk({
417
- baseUrl,
418
- first: { path: "/hook-replay-sender-scope", secret: "secret", payload: firstPayload },
419
- second: { path: "/hook-replay-sender-scope", secret: "secret", payload: secondPayload },
420
- });
421
- });
422
-
423
- expect(sink).toHaveBeenCalledTimes(2);
424
- } finally {
425
- unregister();
426
- }
427
- });
428
-
429
- it("does not throw when replay metadata is partially missing", async () => {
430
- const sink = vi.fn();
431
- const unregister = registerTarget({ path: "/hook-replay-partial", statusSink: sink });
432
- const payload = {
433
- event_name: "message.text.received",
434
- message: {
435
- message_id: "msg-replay-partial-1",
436
- date: Math.floor(Date.now() / 1000),
437
- text: "hello",
438
- },
439
- };
440
-
441
- try {
442
- await withServer(webhookRequestHandler, async (baseUrl) => {
443
- const response = await fetch(`${baseUrl}/hook-replay-partial`, {
444
- method: "POST",
445
- headers: {
446
- "x-bot-api-secret-token": "secret",
447
- "content-type": "application/json",
448
- },
449
- body: JSON.stringify(payload),
450
- });
451
-
452
- expect(response.status).toBe(200);
453
- });
454
-
455
- expect(sink).toHaveBeenCalledTimes(1);
456
- } finally {
457
- unregister();
458
- }
459
- });
460
-
461
- it("keeps replay dedupe isolated when path/account values collide under colon-joined keys", async () => {
462
- const sinkA = vi.fn();
463
- const sinkB = vi.fn();
464
- // Old key format `${path}:${accountId}:${event_name}:${messageId}` would collide for these two targets.
465
- const unregisterA = registerTarget({
466
- path: "/hook-replay-collision:a",
467
- secret: "secret-a",
468
- statusSink: sinkA,
469
- account: {
470
- ...DEFAULT_ACCOUNT,
471
- accountId: "team",
472
- },
473
- });
474
- const unregisterB = registerTarget({
475
- path: "/hook-replay-collision",
476
- secret: "secret-b",
477
- statusSink: sinkB,
478
- account: {
479
- ...DEFAULT_ACCOUNT,
480
- accountId: "a:team",
481
- },
482
- });
483
- const payload = createTextUpdate({
484
- messageId: "msg-replay-collision-1",
485
- userId: "123",
486
- userName: "",
487
- chatId: "123",
488
- text: "hello",
489
- });
490
-
491
- try {
492
- await withServer(webhookRequestHandler, async (baseUrl) => {
493
- await expectTwoWebhookPostsOk({
494
- baseUrl,
495
- first: { path: "/hook-replay-collision:a", secret: "secret-a", payload },
496
- second: { path: "/hook-replay-collision", secret: "secret-b", payload },
497
- });
498
- });
499
-
500
- expect(sinkA).toHaveBeenCalledTimes(1);
501
- expect(sinkB).toHaveBeenCalledTimes(1);
502
- } finally {
503
- unregisterA();
504
- unregisterB();
505
- }
506
- });
507
-
508
- it("keeps replay dedupe isolated across different webhook paths", async () => {
509
- const sinkA = vi.fn();
510
- const sinkB = vi.fn();
511
- const sharedSecret = "secret";
512
- const unregisterA = registerTarget({
513
- path: "/hook-replay-scope-a",
514
- secret: sharedSecret,
515
- statusSink: sinkA,
516
- });
517
- const unregisterB = registerTarget({
518
- path: "/hook-replay-scope-b",
519
- secret: sharedSecret,
520
- statusSink: sinkB,
521
- });
522
- const payload = createTextUpdate({
523
- messageId: "msg-replay-cross-path-1",
524
- userId: "123",
525
- userName: "",
526
- chatId: "123",
527
- text: "hello",
528
- });
529
-
530
- try {
531
- await withServer(webhookRequestHandler, async (baseUrl) => {
532
- await expectTwoWebhookPostsOk({
533
- baseUrl,
534
- first: { path: "/hook-replay-scope-a", secret: sharedSecret, payload },
535
- second: { path: "/hook-replay-scope-b", secret: sharedSecret, payload },
536
- });
537
- });
538
-
539
- expect(sinkA).toHaveBeenCalledTimes(1);
540
- expect(sinkB).toHaveBeenCalledTimes(1);
541
- } finally {
542
- unregisterA();
543
- unregisterB();
544
- }
545
- });
546
-
547
- it("downloads inbound image media from webhook photo_url and preserves display_name", async () => {
548
- const {
549
- core,
550
- finalizeInboundContextMock,
551
- recordInboundSessionMock,
552
- fetchRemoteMediaMock,
553
- saveMediaBufferMock,
554
- } = createImageLifecycleCore();
555
- const unregister = registerTarget({
556
- path: "/hook-image",
557
- core,
558
- account: {
559
- ...DEFAULT_ACCOUNT,
560
- config: {
561
- dmPolicy: "open",
562
- allowFrom: ["*"],
563
- },
564
- },
565
- });
566
- const payload = createImageUpdate();
567
-
568
- try {
569
- await withServer(webhookRequestHandler, async (baseUrl) => {
570
- const response = await fetch(`${baseUrl}/hook-image`, {
571
- method: "POST",
572
- headers: {
573
- "x-bot-api-secret-token": "secret",
574
- "content-type": "application/json",
575
- },
576
- body: JSON.stringify(payload),
577
- });
578
-
579
- expect(response.status).toBe(200);
580
- });
581
- } finally {
582
- unregister();
583
- }
584
-
585
- await vi.waitFor(() => expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1));
586
- expectImageLifecycleDelivery({
587
- fetchRemoteMediaMock,
588
- saveMediaBufferMock,
589
- finalizeInboundContextMock,
590
- recordInboundSessionMock,
591
- });
592
- });
593
-
594
- it("returns 429 when per-path request rate exceeds threshold", async () => {
595
- const unregister = registerTarget({ path: "/hook-rate" });
596
-
597
- try {
598
- await withServer(webhookRequestHandler, async (baseUrl) => {
599
- const saw429 = await postUntilRateLimited({
600
- baseUrl,
601
- path: "/hook-rate",
602
- secret: "secret", // pragma: allowlist secret
603
- });
604
-
605
- expect(saw429).toBe(true);
606
- });
607
- } finally {
608
- unregister();
609
- }
610
- });
611
- it("does not grow status counters when query strings churn on unauthorized requests", async () => {
612
- const unregister = registerTarget({ path: "/hook-query-status" });
613
-
614
- try {
615
- await withServer(webhookRequestHandler, async (baseUrl) => {
616
- let saw429 = false;
617
- for (let i = 0; i < 200; i += 1) {
618
- const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
619
- method: "POST",
620
- headers: {
621
- "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
622
- "content-type": "application/json",
623
- },
624
- body: "{}",
625
- });
626
- expect([401, 429]).toContain(response.status);
627
- if (response.status === 429) {
628
- saw429 = true;
629
- break;
630
- }
631
- }
632
-
633
- expect(saw429).toBe(true);
634
- expect(getZaloWebhookStatusCounterSizeForTest()).toBe(2);
635
- });
636
- } finally {
637
- unregister();
638
- }
639
- });
640
-
641
- it("rate limits authenticated requests even when query strings churn", async () => {
642
- const unregister = registerTarget({ path: "/hook-query-rate" });
643
-
644
- try {
645
- await withServer(webhookRequestHandler, async (baseUrl) => {
646
- const saw429 = await postUntilRateLimited({
647
- baseUrl,
648
- path: "/hook-query-rate",
649
- secret: "secret", // pragma: allowlist secret
650
- withNonceQuery: true,
651
- });
652
-
653
- expect(saw429).toBe(true);
654
- expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
655
- });
656
- } finally {
657
- unregister();
658
- }
659
- });
660
-
661
- it("rate limits unauthorized secret guesses before authentication succeeds", async () => {
662
- const unregister = registerTarget({ path: "/hook-preauth-rate" });
663
-
664
- try {
665
- await withServer(webhookRequestHandler, async (baseUrl) => {
666
- const saw429 = await postUntilRateLimited({
667
- baseUrl,
668
- path: "/hook-preauth-rate",
669
- secret: "invalid-token", // pragma: allowlist secret
670
- withNonceQuery: true,
671
- });
672
-
673
- expect(saw429).toBe(true);
674
- expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
675
- });
676
- } finally {
677
- unregister();
678
- }
679
- });
680
-
681
- it("does not let unauthorized floods rate-limit authenticated traffic from a different trusted forwarded client IP", async () => {
682
- const unregister = registerTarget({
683
- path: "/hook-preauth-split",
684
- config: {
685
- gateway: {
686
- trustedProxies: ["127.0.0.1"],
687
- },
688
- } as OpenClawConfig,
689
- });
690
-
691
- try {
692
- await withServer(webhookRequestHandler, async (baseUrl) => {
693
- for (let i = 0; i < 130; i += 1) {
694
- const response = await fetch(`${baseUrl}/hook-preauth-split?nonce=${i}`, {
695
- method: "POST",
696
- headers: {
697
- "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
698
- "content-type": "application/json",
699
- "x-forwarded-for": "203.0.113.10",
700
- },
701
- body: "{}",
702
- });
703
- if (response.status === 429) {
704
- break;
705
- }
706
- }
707
-
708
- const validResponse = await fetch(`${baseUrl}/hook-preauth-split`, {
709
- method: "POST",
710
- headers: {
711
- "x-bot-api-secret-token": "secret",
712
- "content-type": "application/json",
713
- "x-forwarded-for": "198.51.100.20",
714
- },
715
- body: JSON.stringify({ event_name: "message.unsupported.received" }),
716
- });
717
-
718
- expect(validResponse.status).toBe(200);
719
- });
720
- } finally {
721
- unregister();
722
- }
723
- });
724
-
725
- it("still returns 401 before 415 when both secret and content-type are invalid", async () => {
726
- const unregister = registerTarget({ path: "/hook-auth-before-type" });
727
-
728
- try {
729
- await withServer(webhookRequestHandler, async (baseUrl) => {
730
- const response = await fetch(`${baseUrl}/hook-auth-before-type`, {
731
- method: "POST",
732
- headers: {
733
- "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
734
- "content-type": "text/plain",
735
- },
736
- body: "not-json",
737
- });
738
-
739
- expect(response.status).toBe(401);
740
- });
741
- } finally {
742
- unregister();
743
- }
744
- });
745
-
746
- it("scopes DM pairing store reads and writes to accountId", async () => {
747
- const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
748
- pairingCreated: false,
749
- });
750
- const account: ResolvedZaloAccount = {
751
- ...DEFAULT_ACCOUNT,
752
- accountId: "work",
753
- config: {
754
- dmPolicy: "pairing",
755
- allowFrom: [],
756
- },
757
- };
758
- const unregister = registerTarget({
759
- path: "/hook-account-scope",
760
- account,
761
- core,
762
- });
763
-
764
- const payload = {
765
- event_name: "message.text.received",
766
- message: {
767
- from: { id: "123", name: "Attacker" },
768
- chat: { id: "dm-work", chat_type: "PRIVATE" },
769
- message_id: "msg-work-1",
770
- date: Math.floor(Date.now() / 1000),
771
- text: "hello",
772
- },
773
- };
774
-
775
- try {
776
- await withServer(webhookRequestHandler, async (baseUrl) => {
777
- const response = await fetch(`${baseUrl}/hook-account-scope`, {
778
- method: "POST",
779
- headers: {
780
- "x-bot-api-secret-token": "secret",
781
- "content-type": "application/json",
782
- },
783
- body: JSON.stringify(payload),
784
- });
785
-
786
- expect(response.status).toBe(200);
787
- });
788
- } finally {
789
- unregister();
790
- }
791
-
792
- expect(readAllowFromStore).toHaveBeenCalledWith(
793
- expect.objectContaining({
794
- channel: "zalo",
795
- accountId: "work",
796
- }),
797
- );
798
- expect(upsertPairingRequest).toHaveBeenCalledWith(
799
- expect.objectContaining({
800
- channel: "zalo",
801
- id: "123",
802
- accountId: "work",
803
- }),
804
- );
805
- });
806
- });