@kodelyth/zalo 2026.5.42 → 2026.6.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.
Files changed (67) hide show
  1. package/klaw.plugin.json +509 -2
  2. package/package.json +17 -4
  3. package/api.ts +0 -8
  4. package/channel-plugin-api.ts +0 -1
  5. package/contract-api.ts +0 -5
  6. package/index.test.ts +0 -15
  7. package/index.ts +0 -20
  8. package/runtime-api.test.ts +0 -10
  9. package/runtime-api.ts +0 -71
  10. package/secret-contract-api.ts +0 -5
  11. package/setup-api.ts +0 -34
  12. package/setup-entry.ts +0 -13
  13. package/src/accounts.test.ts +0 -95
  14. package/src/accounts.ts +0 -65
  15. package/src/actions.runtime.ts +0 -5
  16. package/src/actions.test.ts +0 -32
  17. package/src/actions.ts +0 -62
  18. package/src/api.test.ts +0 -166
  19. package/src/api.ts +0 -265
  20. package/src/approval-auth.test.ts +0 -17
  21. package/src/approval-auth.ts +0 -25
  22. package/src/channel.directory.test.ts +0 -56
  23. package/src/channel.runtime.ts +0 -89
  24. package/src/channel.startup.test.ts +0 -121
  25. package/src/channel.ts +0 -309
  26. package/src/config-schema.test.ts +0 -30
  27. package/src/config-schema.ts +0 -29
  28. package/src/group-access.ts +0 -23
  29. package/src/monitor-durable.test.ts +0 -49
  30. package/src/monitor-durable.ts +0 -38
  31. package/src/monitor.group-policy.test.ts +0 -213
  32. package/src/monitor.image.polling.test.ts +0 -113
  33. package/src/monitor.lifecycle.test.ts +0 -194
  34. package/src/monitor.pairing.lifecycle.test.ts +0 -139
  35. package/src/monitor.polling.media-reply.test.ts +0 -433
  36. package/src/monitor.reply-once.lifecycle.test.ts +0 -178
  37. package/src/monitor.ts +0 -1009
  38. package/src/monitor.types.ts +0 -4
  39. package/src/monitor.webhook.test.ts +0 -808
  40. package/src/monitor.webhook.ts +0 -278
  41. package/src/outbound-media.test.ts +0 -186
  42. package/src/outbound-media.ts +0 -236
  43. package/src/outbound-payload.contract.test.ts +0 -143
  44. package/src/probe.ts +0 -45
  45. package/src/proxy.ts +0 -18
  46. package/src/runtime-api.ts +0 -71
  47. package/src/runtime-support.ts +0 -82
  48. package/src/runtime.ts +0 -9
  49. package/src/secret-contract.ts +0 -109
  50. package/src/secret-input.ts +0 -5
  51. package/src/send.test.ts +0 -150
  52. package/src/send.ts +0 -207
  53. package/src/session-route.ts +0 -32
  54. package/src/setup-allow-from.ts +0 -97
  55. package/src/setup-core.ts +0 -152
  56. package/src/setup-status.test.ts +0 -33
  57. package/src/setup-surface.test.ts +0 -193
  58. package/src/setup-surface.ts +0 -294
  59. package/src/status-issues.test.ts +0 -17
  60. package/src/status-issues.ts +0 -34
  61. package/src/test-support/lifecycle-test-support.ts +0 -456
  62. package/src/test-support/monitor-mocks-test-support.ts +0 -209
  63. package/src/token.test.ts +0 -92
  64. package/src/token.ts +0 -79
  65. package/src/types.ts +0 -50
  66. package/test-api.ts +0 -1
  67. package/tsconfig.json +0 -16
@@ -1,808 +0,0 @@
1
- import type { RequestListener } from "node:http";
2
- import {
3
- createEmptyPluginRegistry,
4
- setActivePluginRegistry,
5
- } from "klaw/plugin-sdk/plugin-test-runtime";
6
- import { withServer } from "klaw/plugin-sdk/test-env";
7
- import { afterEach, describe, expect, it, vi } from "vitest";
8
- import type { KlawConfig, 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?: KlawConfig;
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 KlawConfig),
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("accepts replay metadata when optional fields are 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
- readRemoteMediaBufferMock,
553
- saveRemoteMediaMock,
554
- saveMediaBufferMock,
555
- } = createImageLifecycleCore();
556
- const unregister = registerTarget({
557
- path: "/hook-image",
558
- core,
559
- account: {
560
- ...DEFAULT_ACCOUNT,
561
- config: {
562
- dmPolicy: "open",
563
- allowFrom: ["*"],
564
- },
565
- },
566
- });
567
- const payload = createImageUpdate();
568
-
569
- try {
570
- await withServer(webhookRequestHandler, async (baseUrl) => {
571
- const response = await fetch(`${baseUrl}/hook-image`, {
572
- method: "POST",
573
- headers: {
574
- "x-bot-api-secret-token": "secret",
575
- "content-type": "application/json",
576
- },
577
- body: JSON.stringify(payload),
578
- });
579
-
580
- expect(response.status).toBe(200);
581
- });
582
- } finally {
583
- unregister();
584
- }
585
-
586
- await vi.waitFor(() => expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1));
587
- expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
588
- expectImageLifecycleDelivery({
589
- readRemoteMediaBufferMock,
590
- saveRemoteMediaMock,
591
- saveMediaBufferMock,
592
- finalizeInboundContextMock,
593
- recordInboundSessionMock,
594
- });
595
- });
596
-
597
- it("returns 429 when per-path request rate exceeds threshold", async () => {
598
- const unregister = registerTarget({ path: "/hook-rate" });
599
-
600
- try {
601
- await withServer(webhookRequestHandler, async (baseUrl) => {
602
- const saw429 = await postUntilRateLimited({
603
- baseUrl,
604
- path: "/hook-rate",
605
- secret: "secret", // pragma: allowlist secret
606
- });
607
-
608
- expect(saw429).toBe(true);
609
- });
610
- } finally {
611
- unregister();
612
- }
613
- });
614
- it("does not grow status counters when query strings churn on unauthorized requests", async () => {
615
- const unregister = registerTarget({ path: "/hook-query-status" });
616
-
617
- try {
618
- await withServer(webhookRequestHandler, async (baseUrl) => {
619
- let saw429 = false;
620
- for (let i = 0; i < 200; i += 1) {
621
- const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
622
- method: "POST",
623
- headers: {
624
- "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
625
- "content-type": "application/json",
626
- },
627
- body: "{}",
628
- });
629
- expect([401, 429]).toContain(response.status);
630
- if (response.status === 429) {
631
- saw429 = true;
632
- break;
633
- }
634
- }
635
-
636
- expect(saw429).toBe(true);
637
- expect(getZaloWebhookStatusCounterSizeForTest()).toBe(2);
638
- });
639
- } finally {
640
- unregister();
641
- }
642
- });
643
-
644
- it("rate limits authenticated requests even when query strings churn", async () => {
645
- const unregister = registerTarget({ path: "/hook-query-rate" });
646
-
647
- try {
648
- await withServer(webhookRequestHandler, async (baseUrl) => {
649
- const saw429 = await postUntilRateLimited({
650
- baseUrl,
651
- path: "/hook-query-rate",
652
- secret: "secret", // pragma: allowlist secret
653
- withNonceQuery: true,
654
- });
655
-
656
- expect(saw429).toBe(true);
657
- expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
658
- });
659
- } finally {
660
- unregister();
661
- }
662
- });
663
-
664
- it("rate limits unauthorized secret guesses before authentication succeeds", async () => {
665
- const unregister = registerTarget({ path: "/hook-preauth-rate" });
666
-
667
- try {
668
- await withServer(webhookRequestHandler, async (baseUrl) => {
669
- const saw429 = await postUntilRateLimited({
670
- baseUrl,
671
- path: "/hook-preauth-rate",
672
- secret: "invalid-token", // pragma: allowlist secret
673
- withNonceQuery: true,
674
- });
675
-
676
- expect(saw429).toBe(true);
677
- expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
678
- });
679
- } finally {
680
- unregister();
681
- }
682
- });
683
-
684
- it("does not let unauthorized floods rate-limit authenticated traffic from a different trusted forwarded client IP", async () => {
685
- const unregister = registerTarget({
686
- path: "/hook-preauth-split",
687
- config: {
688
- gateway: {
689
- trustedProxies: ["127.0.0.1"],
690
- },
691
- } as KlawConfig,
692
- });
693
-
694
- try {
695
- await withServer(webhookRequestHandler, async (baseUrl) => {
696
- for (let i = 0; i < 130; i += 1) {
697
- const response = await fetch(`${baseUrl}/hook-preauth-split?nonce=${i}`, {
698
- method: "POST",
699
- headers: {
700
- "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
701
- "content-type": "application/json",
702
- "x-forwarded-for": "203.0.113.10",
703
- },
704
- body: "{}",
705
- });
706
- if (response.status === 429) {
707
- break;
708
- }
709
- }
710
-
711
- const validResponse = await fetch(`${baseUrl}/hook-preauth-split`, {
712
- method: "POST",
713
- headers: {
714
- "x-bot-api-secret-token": "secret",
715
- "content-type": "application/json",
716
- "x-forwarded-for": "198.51.100.20",
717
- },
718
- body: JSON.stringify({ event_name: "message.unsupported.received" }),
719
- });
720
-
721
- expect(validResponse.status).toBe(200);
722
- });
723
- } finally {
724
- unregister();
725
- }
726
- });
727
-
728
- it("still returns 401 before 415 when both secret and content-type are invalid", async () => {
729
- const unregister = registerTarget({ path: "/hook-auth-before-type" });
730
-
731
- try {
732
- await withServer(webhookRequestHandler, async (baseUrl) => {
733
- const response = await fetch(`${baseUrl}/hook-auth-before-type`, {
734
- method: "POST",
735
- headers: {
736
- "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
737
- "content-type": "text/plain",
738
- },
739
- body: "not-json",
740
- });
741
-
742
- expect(response.status).toBe(401);
743
- });
744
- } finally {
745
- unregister();
746
- }
747
- });
748
-
749
- it("scopes DM pairing store reads and writes to accountId", async () => {
750
- const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
751
- pairingCreated: false,
752
- });
753
- const account: ResolvedZaloAccount = {
754
- ...DEFAULT_ACCOUNT,
755
- accountId: "work",
756
- config: {
757
- dmPolicy: "pairing",
758
- allowFrom: [],
759
- },
760
- };
761
- const unregister = registerTarget({
762
- path: "/hook-account-scope",
763
- account,
764
- core,
765
- });
766
-
767
- const payload = {
768
- event_name: "message.text.received",
769
- message: {
770
- from: { id: "123", name: "Attacker" },
771
- chat: { id: "dm-work", chat_type: "PRIVATE" },
772
- message_id: "msg-work-1",
773
- date: Math.floor(Date.now() / 1000),
774
- text: "hello",
775
- },
776
- };
777
-
778
- try {
779
- await withServer(webhookRequestHandler, async (baseUrl) => {
780
- const response = await fetch(`${baseUrl}/hook-account-scope`, {
781
- method: "POST",
782
- headers: {
783
- "x-bot-api-secret-token": "secret",
784
- "content-type": "application/json",
785
- },
786
- body: JSON.stringify(payload),
787
- });
788
-
789
- expect(response.status).toBe(200);
790
- });
791
- } finally {
792
- unregister();
793
- }
794
-
795
- expect(readAllowFromStore).toHaveBeenCalledTimes(1);
796
- expect(readAllowFromStore).toHaveBeenCalledWith({
797
- channel: "zalo",
798
- accountId: "work",
799
- });
800
- expect(upsertPairingRequest).toHaveBeenCalledTimes(1);
801
- expect(upsertPairingRequest).toHaveBeenCalledWith({
802
- channel: "zalo",
803
- accountId: "work",
804
- id: "123",
805
- meta: { name: "Attacker" },
806
- });
807
- });
808
- });