switchroom 0.21.11 → 0.21.13

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.
@@ -0,0 +1,588 @@
1
+ /**
2
+ * Wire-level OUTCOME tests for the UTF-8 sanitiser (#4728).
3
+ *
4
+ * These reproduce the production drop, they do not merely exercise the
5
+ * helper. The stubbed transport below is a FAITHFUL stand-in for Telegram's
6
+ * decoder: it parses the serialized request body and answers
7
+ * `400 Bad Request: strings must be encoded in UTF-8` whenever any string in
8
+ * it contains an unpaired surrogate — which is exactly what the real server
9
+ * did to `gymbro`'s `permission_request` card on 2026-07-31 (twice), and why
10
+ * the operator never saw that approval card.
11
+ *
12
+ * `JSON.stringify` does NOT throw on a lone surrogate (well-formed
13
+ * `JSON.stringify`, ES2019) — it emits the `\udXXX` escape — so the bad body
14
+ * really does reach the wire, and the failure really is server-side.
15
+ *
16
+ * Delete `installUtf8Sanitizer(bot)` from `makeTelegramLikeBot` (or from
17
+ * `initGatewayBot`) and every `(a)`-`(d)` case below goes RED with the
18
+ * production GrammyError, because the send REJECTS instead of delivering.
19
+ */
20
+ import { describe, it, expect } from 'vitest'
21
+ import { readFileSync } from 'node:fs'
22
+ import { fileURLToPath } from 'node:url'
23
+ import { dirname, resolve } from 'node:path'
24
+ import { Bot, GrammyError } from 'grammy'
25
+ import { installTgPostLogger, installRichMarkdownGuard } from '../shared/bot-runtime.js'
26
+ import {
27
+ installUtf8Sanitizer,
28
+ sanitizeLoneSurrogates,
29
+ sanitizePayloadStrings,
30
+ hasLoneSurrogate,
31
+ } from '../shared/utf8-sanitize.js'
32
+
33
+ /** An unpaired HIGH surrogate — no valid UTF-8 encoding exists for it. */
34
+ const LONE_HIGH = '\uD800'
35
+ /** An unpaired LOW surrogate — the half every truncation site in the plugin
36
+ * fails to repair (they all only strip a TRAILING high surrogate). */
37
+ const LONE_LOW = '\uDC00'
38
+ /** A well-formed astral pair (🤖). Must survive untouched. */
39
+ const ASTRAL = '\u{1F916}'
40
+
41
+ function anyLoneSurrogate(v: unknown): boolean {
42
+ if (typeof v === 'string') return hasLoneSurrogate(v)
43
+ if (Array.isArray(v)) return v.some(anyLoneSurrogate)
44
+ if (v !== null && typeof v === 'object') return Object.values(v).some(anyLoneSurrogate)
45
+ return false
46
+ }
47
+
48
+ interface CapturedCall {
49
+ method: string
50
+ body: Record<string, unknown>
51
+ }
52
+
53
+ /**
54
+ * A real grammy Bot wired with the production transformer stack, whose
55
+ * transport rejects a non-UTF-8-encodable body the way Telegram does.
56
+ */
57
+ function makeTelegramLikeBot(now?: () => number): { bot: Bot; calls: CapturedCall[] } {
58
+ const calls: CapturedCall[] = []
59
+ const fakeFetch = (async (url: unknown, init?: { body?: unknown }) => {
60
+ const method = String(url).split('/').pop() ?? ''
61
+ let body: Record<string, unknown> = {}
62
+ if (typeof init?.body === 'string') {
63
+ body = JSON.parse(init.body) as Record<string, unknown>
64
+ }
65
+ calls.push({ method, body })
66
+ if (anyLoneSurrogate(body)) {
67
+ return {
68
+ ok: true,
69
+ status: 200,
70
+ json: async () => ({
71
+ ok: false,
72
+ error_code: 400,
73
+ description: 'Bad Request: strings must be encoded in UTF-8',
74
+ }),
75
+ } as unknown as Response
76
+ }
77
+ return {
78
+ ok: true,
79
+ status: 200,
80
+ json: async () => ({
81
+ ok: true,
82
+ result: { message_id: 1, date: 0, chat: { id: 1, type: 'private' } },
83
+ }),
84
+ } as unknown as Response
85
+ }) as unknown as typeof fetch
86
+
87
+ const bot = new Bot('123456:TEST_TOKEN', {
88
+ botInfo: {
89
+ id: 123456,
90
+ is_bot: true,
91
+ first_name: 'Test',
92
+ username: 'test_bot',
93
+ can_join_groups: false,
94
+ can_read_all_group_messages: false,
95
+ supports_inline_queries: false,
96
+ can_connect_to_business: false,
97
+ has_main_web_app: false,
98
+ },
99
+ client: { fetch: fakeFetch },
100
+ })
101
+ // Production ordering (initGatewayBot): sanitiser FIRST so it composes
102
+ // INNERMOST and is the last transformer to touch the payload.
103
+ installUtf8Sanitizer(bot, now)
104
+ installTgPostLogger(bot)
105
+ installRichMarkdownGuard(bot)
106
+ return { bot, calls }
107
+ }
108
+
109
+ function lastBody(calls: CapturedCall[]): Record<string, unknown> {
110
+ return calls[calls.length - 1].body
111
+ }
112
+
113
+ describe('UTF-8 sanitiser — the dropped approval card (#4728)', () => {
114
+ it('(a) an approval-card body with a lone surrogate DELIVERS instead of 400ing', async () => {
115
+ const { bot, calls } = makeTelegramLikeBot()
116
+ // Shape of a real permission card: rich markdown + the Approve/Deny row.
117
+ const sent = await bot.api.sendRichMessage(
118
+ 1,
119
+ { markdown: `**Approve?**\n\`Bash\` — rm -rf ${LONE_HIGH}tmp` },
120
+ {
121
+ reply_markup: {
122
+ inline_keyboard: [[{ text: 'Approve', callback_data: 'perm:allow:1' }]],
123
+ },
124
+ },
125
+ )
126
+ expect(sent.message_id).toBe(1)
127
+ expect(calls[calls.length - 1].method).toBe('sendRichMessage')
128
+ const markdown = (lastBody(calls).rich_message as { markdown: string }).markdown
129
+ expect(markdown).toBe('**Approve?**\n`Bash` — rm -rf �tmp')
130
+ expect(hasLoneSurrogate(markdown)).toBe(false)
131
+ })
132
+
133
+ it('(b) an orphaned LOW surrogate — the half no truncation site repairs — also delivers', async () => {
134
+ const { bot, calls } = makeTelegramLikeBot()
135
+ const sent = await bot.api.sendRichMessage(1, { markdown: `${LONE_LOW} continued` })
136
+ expect(sent.message_id).toBe(1)
137
+ expect((lastBody(calls).rich_message as { markdown: string }).markdown).toBe('� continued')
138
+ })
139
+
140
+ it('(c) a lone surrogate in an inline-keyboard LABEL delivers (whole payload, not one field)', async () => {
141
+ const { bot, calls } = makeTelegramLikeBot()
142
+ const sent = await bot.api.sendRichMessage(
143
+ 1,
144
+ { markdown: 'clean body' },
145
+ {
146
+ reply_markup: {
147
+ inline_keyboard: [[{ text: `Always allow ${LONE_HIGH}`, callback_data: 'perm:always:1' }]],
148
+ },
149
+ },
150
+ )
151
+ expect(sent.message_id).toBe(1)
152
+ const kb = (lastBody(calls).reply_markup as {
153
+ inline_keyboard: { text: string }[][]
154
+ }).inline_keyboard
155
+ expect(kb[0][0].text).toBe('Always allow �')
156
+ })
157
+
158
+ it('(d) an ORDINARY sendMessage with a lone surrogate delivers too', async () => {
159
+ const { bot, calls } = makeTelegramLikeBot()
160
+ const sent = await bot.api.sendMessage(1, `answer${LONE_HIGH}`)
161
+ expect(sent.message_id).toBe(1)
162
+ expect(lastBody(calls).text).toBe('answer�')
163
+ })
164
+
165
+ it('(e) proves the harness is honest: an UNSANITISED bot really does 400', async () => {
166
+ // Same transport, sanitiser NOT installed — the pre-fix production path.
167
+ // Without this case, (a)-(d) could pass against a permissive stub.
168
+ const calls: CapturedCall[] = []
169
+ const fakeFetch = (async (url: unknown, init?: { body?: unknown }) => {
170
+ const body = typeof init?.body === 'string'
171
+ ? (JSON.parse(init.body) as Record<string, unknown>)
172
+ : {}
173
+ calls.push({ method: String(url).split('/').pop() ?? '', body })
174
+ return {
175
+ ok: true,
176
+ status: 200,
177
+ json: async () => (anyLoneSurrogate(body)
178
+ ? { ok: false, error_code: 400, description: 'Bad Request: strings must be encoded in UTF-8' }
179
+ : { ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: 'private' } } }),
180
+ } as unknown as Response
181
+ }) as unknown as typeof fetch
182
+ const bot = new Bot('123456:TEST_TOKEN', {
183
+ botInfo: {
184
+ id: 123456, is_bot: true, first_name: 'Test', username: 'test_bot',
185
+ can_join_groups: false, can_read_all_group_messages: false,
186
+ supports_inline_queries: false, can_connect_to_business: false,
187
+ has_main_web_app: false,
188
+ },
189
+ client: { fetch: fakeFetch },
190
+ })
191
+ installTgPostLogger(bot)
192
+ installRichMarkdownGuard(bot)
193
+ await expect(
194
+ bot.api.sendRichMessage(1, { markdown: `**Approve?** ${LONE_HIGH}` }),
195
+ ).rejects.toThrow(GrammyError)
196
+ await expect(
197
+ bot.api.sendRichMessage(1, { markdown: `**Approve?** ${LONE_HIGH}` }),
198
+ ).rejects.toThrow(/strings must be encoded in UTF-8/)
199
+ })
200
+
201
+ it('(f) a clean body is byte-identical and the payload object is not cloned', async () => {
202
+ const { bot, calls } = makeTelegramLikeBot()
203
+ const sent = await bot.api.sendRichMessage(1, { markdown: `all good ${ASTRAL} 100% ✓` })
204
+ expect(sent.message_id).toBe(1)
205
+ expect((lastBody(calls).rich_message as { markdown: string }).markdown)
206
+ .toBe(`all good ${ASTRAL} 100% ✓`)
207
+ // Identity no-op is the contract the transformer relies on to stay free.
208
+ const payload = { chat_id: 1, text: 'clean' }
209
+ expect(sanitizePayloadStrings(payload)).toBe(payload)
210
+ })
211
+
212
+ it('(f2) a clean payload is not rebuilt at ANY depth — no container allocated, no property defined', () => {
213
+ // (f) only pins the identity of the TOP-level return, which a walk that
214
+ // clones every nested container and discards the clones still satisfies.
215
+ // This transformer is the innermost hop of every `bot.api.*` call and the
216
+ // draft-stream `editMessageText` path runs it several times a second, so
217
+ // "the clean case rebuilds nothing" is a real contract, not a comment.
218
+ const button = { text: `Approve ${ASTRAL}`, callback_data: 'perm:allow:1' }
219
+ const row = [button]
220
+ const keyboard = [row]
221
+ const markup = { inline_keyboard: keyboard }
222
+ const payload = { chat_id: 1, text: `all good ${ASTRAL} 100% ✓`, reply_markup: markup }
223
+
224
+ const realDefineProperty = Object.defineProperty
225
+ let definePropertyCalls = 0
226
+ Object.defineProperty = ((...args: Parameters<typeof Object.defineProperty>) => {
227
+ definePropertyCalls++
228
+ return realDefineProperty(...args)
229
+ }) as typeof Object.defineProperty
230
+ let out: typeof payload
231
+ try {
232
+ out = sanitizePayloadStrings(payload)
233
+ } finally {
234
+ Object.defineProperty = realDefineProperty
235
+ }
236
+
237
+ // Goes RED on an eager clone: the old walk defined one property per key
238
+ // at every level before deciding nothing had changed.
239
+ expect(definePropertyCalls).toBe(0)
240
+ // Every container comes back by identity, top level down to the button.
241
+ expect(out).toBe(payload)
242
+ expect(out.reply_markup).toBe(markup)
243
+ expect(out.reply_markup.inline_keyboard).toBe(keyboard)
244
+ expect(out.reply_markup.inline_keyboard[0]).toBe(row)
245
+ expect(out.reply_markup.inline_keyboard[0][0]).toBe(button)
246
+ })
247
+
248
+ it('(f3) a DIRTY payload still rebuilds correctly from the lazily-materialised clone', () => {
249
+ // The lazy clone backfills the already-walked (clean) prefix. If that
250
+ // backfill were wrong, keys before the first repair would vanish.
251
+ const payload = {
252
+ chat_id: 1,
253
+ before_a: 'kept a',
254
+ before_b: 'kept b',
255
+ text: `boom${LONE_HIGH}`,
256
+ after: 'kept c',
257
+ nested: { keep: 'yes', bad: `${LONE_LOW}x` },
258
+ arr: ['keep 0', 'keep 1', `bad${LONE_HIGH}`, 'keep 3'],
259
+ }
260
+ const out = sanitizePayloadStrings(payload)
261
+ expect(out).not.toBe(payload)
262
+ expect(Object.keys(out)).toEqual(Object.keys(payload))
263
+ expect(out.before_a).toBe('kept a')
264
+ expect(out.before_b).toBe('kept b')
265
+ expect(out.text).toBe('boom�')
266
+ expect(out.after).toBe('kept c')
267
+ expect(out.nested).toEqual({ keep: 'yes', bad: '�x' })
268
+ expect(out.arr).toEqual(['keep 0', 'keep 1', 'bad�', 'keep 3'])
269
+ // Clone-on-write: the input itself is untouched.
270
+ expect(payload.text).toBe(`boom${LONE_HIGH}`)
271
+ expect(payload.arr[2]).toBe(`bad${LONE_HIGH}`)
272
+ })
273
+
274
+ it('(g) a payload whose own enumerable getter THROWS still sends — the sanitiser fails open', async () => {
275
+ // The walk reads own enumerable properties, which invokes getters. This
276
+ // transformer sits on the innermost hop of EVERY `bot.api.*` call, so an
277
+ // exception escaping it would wedge the whole gateway, not one send.
278
+ //
279
+ // Ordering here is deliberately inverted relative to production (the
280
+ // recorder is installed first, so it is INNERMOST and answers without
281
+ // serialising) purely to isolate what the sanitiser hands downstream.
282
+ // Production ordering is pinned by the boot-wiring describe below.
283
+ const seen: unknown[] = []
284
+ const bot = new Bot('123456:TEST_TOKEN', {
285
+ botInfo: {
286
+ id: 123456, is_bot: true, first_name: 'Test', username: 'test_bot',
287
+ can_join_groups: false, can_read_all_group_messages: false,
288
+ supports_inline_queries: false, can_connect_to_business: false,
289
+ has_main_web_app: false,
290
+ },
291
+ })
292
+ bot.api.config.use(async (_prev, _method, payload) => {
293
+ seen.push(payload)
294
+ return {
295
+ ok: true,
296
+ result: { message_id: 7, date: 0, chat: { id: 1, type: 'private' } },
297
+ } as never
298
+ })
299
+ installUtf8Sanitizer(bot)
300
+
301
+ const payload: Record<string, unknown> = { chat_id: 1 }
302
+ Object.defineProperty(payload, 'text', {
303
+ enumerable: true,
304
+ configurable: true,
305
+ get() {
306
+ throw new Error('getter exploded')
307
+ },
308
+ })
309
+
310
+ // Load-bearing: without the guard in `installUtf8Sanitizer` this is the
311
+ // exception that would escape into every outbound call.
312
+ expect(() => sanitizePayloadStrings(payload)).toThrow('getter exploded')
313
+
314
+ const sent = await bot.api.raw.sendMessage(payload as never)
315
+ expect(sent.message_id).toBe(7)
316
+ // Failed open: the ORIGINAL payload was passed through, not swallowed.
317
+ expect(seen).toHaveLength(1)
318
+ expect(seen[0]).toBe(payload)
319
+ })
320
+ })
321
+
322
+ describe('sanitizeLoneSurrogates / sanitizePayloadStrings — unit contract', () => {
323
+ it('preserves well-formed pairs, including adjacent ones', () => {
324
+ const s = `${ASTRAL}${ASTRAL}ok`
325
+ expect(sanitizeLoneSurrogates(s)).toBe(s)
326
+ expect(hasLoneSurrogate(s)).toBe(false)
327
+ })
328
+
329
+ it('repairs both halves and is length-preserving', () => {
330
+ expect(sanitizeLoneSurrogates(`a${LONE_HIGH}b`)).toBe('a�b')
331
+ expect(sanitizeLoneSurrogates(`a${LONE_LOW}b`)).toBe('a�b')
332
+ expect(sanitizeLoneSurrogates(`a${LONE_HIGH}b`).length).toBe(3)
333
+ })
334
+
335
+ it('repairs a high surrogate followed by another high surrogate (both lone)', () => {
336
+ expect(sanitizeLoneSurrogates(`${LONE_HIGH}${LONE_HIGH}`)).toBe('��')
337
+ })
338
+
339
+ it('is idempotent', () => {
340
+ const once = sanitizeLoneSurrogates(`x${LONE_HIGH}y`)
341
+ expect(sanitizeLoneSurrogates(once)).toBe(once)
342
+ })
343
+
344
+ it('is stateless across calls (the shared /g regex must not carry lastIndex)', () => {
345
+ for (let i = 0; i < 4; i++) {
346
+ expect(hasLoneSurrogate(`ab${LONE_HIGH}`)).toBe(true)
347
+ expect(sanitizeLoneSurrogates(`ab${LONE_HIGH}`)).toBe('ab�')
348
+ }
349
+ })
350
+
351
+ it('does not rebuild non-plain objects (an InputFile-like instance survives by identity)', () => {
352
+ class InputFileLike {
353
+ constructor(public filename: string) {}
354
+ }
355
+ const file = new InputFileLike('photo.png')
356
+ const payload = { chat_id: 1, photo: file, caption: `hi${LONE_HIGH}` }
357
+ const out = sanitizePayloadStrings(payload)
358
+ expect(out).not.toBe(payload)
359
+ expect(out.caption).toBe('hi�')
360
+ expect(out.photo).toBe(file)
361
+ expect(out.photo).toBeInstanceOf(InputFileLike)
362
+ })
363
+
364
+ it('does not copy INHERITED enumerable keys into the clone (prototype pollution)', () => {
365
+ // The walk uses `for...in`, which unlike `Object.entries` also yields
366
+ // inherited enumerable keys. Without the own-property guard a polluted
367
+ // `Object.prototype` would add a field to the body sent to Telegram.
368
+ const proto = Object.prototype as unknown as Record<string, unknown>
369
+ Object.defineProperty(proto, '__polluted__', {
370
+ value: `evil${LONE_HIGH}`,
371
+ writable: true,
372
+ enumerable: true,
373
+ configurable: true,
374
+ })
375
+ try {
376
+ const payload = { chat_id: 1, text: `hi${LONE_HIGH}` }
377
+ const out = sanitizePayloadStrings(payload)
378
+ expect(out.text).toBe('hi�')
379
+ expect(Object.prototype.hasOwnProperty.call(out, '__polluted__')).toBe(false)
380
+ expect(Object.keys(out)).toEqual(['chat_id', 'text'])
381
+ expect(JSON.stringify(out)).toBe('{"chat_id":1,"text":"hi�"}')
382
+ } finally {
383
+ delete proto.__polluted__
384
+ }
385
+ })
386
+
387
+ it('leaves non-string scalars alone', () => {
388
+ const payload = { chat_id: 1, disable_notification: true, x: null, y: undefined }
389
+ expect(sanitizePayloadStrings(payload)).toBe(payload)
390
+ })
391
+
392
+ it('handles a null/undefined payload without throwing (grammy passes those)', () => {
393
+ expect(sanitizePayloadStrings(undefined)).toBe(undefined)
394
+ expect(sanitizePayloadStrings(null)).toBe(null)
395
+ })
396
+
397
+ it('keeps an own `__proto__` key as an own property of the clone', () => {
398
+ // `out['__proto__'] = v` hits the Object.prototype accessor and reassigns
399
+ // the clone's PROTOTYPE instead of creating an own property — the key
400
+ // would vanish from the payload entirely.
401
+ const payload: Record<string, unknown> = { chat_id: 1, text: `hi${LONE_HIGH}` }
402
+ Object.defineProperty(payload, '__proto__', {
403
+ value: `p${LONE_LOW}`,
404
+ writable: true,
405
+ enumerable: true,
406
+ configurable: true,
407
+ })
408
+ const out = sanitizePayloadStrings(payload)
409
+ expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(true)
410
+ expect(Object.getOwnPropertyDescriptor(out, '__proto__')?.value).toBe('p�')
411
+ expect(Object.getPrototypeOf(out)).toBe(Object.prototype)
412
+ // And it survives serialisation, which is the only thing the wire sees.
413
+ expect(JSON.stringify(out)).toContain('"__proto__":"p�"')
414
+ expect(out.text).toBe('hi�')
415
+ })
416
+ })
417
+
418
+ describe('repair logging: real count, throttled per method', () => {
419
+ /**
420
+ * Swap `process.stderr.write` directly rather than through `vi`: this file
421
+ * runs under BOTH vitest and `bun test`, and the mock/timer halves of `vi`
422
+ * are not equivalent across the two.
423
+ */
424
+ async function captureSanitizeLog(fn: (lines: string[]) => Promise<void>): Promise<void> {
425
+ const lines: string[] = []
426
+ const real = process.stderr.write.bind(process.stderr)
427
+ process.stderr.write = ((chunk: unknown) => {
428
+ const s = String(chunk)
429
+ if (s.includes('utf8-sanitize')) lines.push(s)
430
+ return true
431
+ }) as typeof process.stderr.write
432
+ try {
433
+ await fn(lines)
434
+ } finally {
435
+ process.stderr.write = real
436
+ }
437
+ }
438
+
439
+ it('reports how many code units were repaired, and logs once per method per minute', async () => {
440
+ let clock = 1_000_000
441
+ await captureSanitizeLog(async lines => {
442
+ const { bot } = makeTelegramLikeBot(() => clock)
443
+
444
+ // Two lone surrogates in one body: a high with no follower, a low with
445
+ // no leader. The docblock promises a count, so the line must carry one.
446
+ await bot.api.sendMessage(1, `a${LONE_HIGH}b${LONE_LOW}`)
447
+ expect(lines).toHaveLength(1)
448
+ expect(lines[0]).toContain('repaired 2 lone surrogate(s)')
449
+ expect(lines[0]).toContain('method=sendMessage')
450
+ // Never the content itself.
451
+ expect(lines[0]).not.toContain('a�b')
452
+
453
+ // Same method inside the window — a persistently corrupt draft stream
454
+ // must not emit one line per edit.
455
+ clock += 500
456
+ await bot.api.sendMessage(1, `c${LONE_HIGH}`)
457
+ clock += 500
458
+ await bot.api.sendMessage(1, `d${LONE_HIGH}`)
459
+ expect(lines).toHaveLength(1)
460
+
461
+ // A different method has its own budget.
462
+ await bot.api.editMessageText(1, 1, `e${LONE_HIGH}`)
463
+ expect(lines).toHaveLength(2)
464
+ expect(lines[1]).toContain('method=editMessageText')
465
+ expect(lines[1]).toContain('repaired 1 lone surrogate(s)')
466
+
467
+ // Next window: the corruption is still diagnosable.
468
+ clock += 61_000
469
+ await bot.api.sendMessage(1, `f${LONE_HIGH}`)
470
+ expect(lines).toHaveLength(3)
471
+ expect(lines[2]).toContain('method=sendMessage')
472
+ })
473
+ })
474
+
475
+ it('failing open is LOGGED, not silent, and names the method', async () => {
476
+ // Fail-open is correct (see the docblock) but a silent fail-open turns a
477
+ // future walk bug into a bare Telegram 400 with no trail back here — the
478
+ // exact opacity #4728 exists to end. Goes RED on a bare `catch {}`.
479
+ let clock = 2_000_000
480
+ await captureSanitizeLog(async lines => {
481
+ const bot = new Bot('123456:TEST_TOKEN', {
482
+ botInfo: {
483
+ id: 123456, is_bot: true, first_name: 'Test', username: 'test_bot',
484
+ can_join_groups: false, can_read_all_group_messages: false,
485
+ supports_inline_queries: false, can_connect_to_business: false,
486
+ has_main_web_app: false,
487
+ },
488
+ })
489
+ bot.api.config.use(async () => ({
490
+ ok: true,
491
+ result: { message_id: 7, date: 0, chat: { id: 1, type: 'private' } },
492
+ } as never))
493
+ installUtf8Sanitizer(bot, () => clock)
494
+
495
+ const explode = (): Record<string, unknown> => {
496
+ const p: Record<string, unknown> = { chat_id: 1 }
497
+ Object.defineProperty(p, 'text', {
498
+ enumerable: true,
499
+ configurable: true,
500
+ get() { throw new Error('getter exploded') },
501
+ })
502
+ return p
503
+ }
504
+
505
+ // Still sends (fail-open), and says so.
506
+ const sent = await bot.api.raw.sendMessage(explode() as never)
507
+ expect(sent.message_id).toBe(7)
508
+ expect(lines).toHaveLength(1)
509
+ expect(lines[0]).toContain('FAILED OPEN')
510
+ expect(lines[0]).toContain('method=sendMessage')
511
+ expect(lines[0]).toContain('getter exploded')
512
+
513
+ // Throttled on its own budget, like the repair line.
514
+ clock += 500
515
+ await bot.api.raw.sendMessage(explode() as never)
516
+ expect(lines).toHaveLength(1)
517
+
518
+ clock += 61_000
519
+ await bot.api.raw.sendMessage(explode() as never)
520
+ expect(lines).toHaveLength(2)
521
+ expect(lines[1]).toContain('FAILED OPEN')
522
+ })
523
+ })
524
+
525
+ it('logs nothing for a clean body', async () => {
526
+ await captureSanitizeLog(async lines => {
527
+ const { bot } = makeTelegramLikeBot()
528
+ await bot.api.sendMessage(1, `all good ${ASTRAL}`)
529
+ expect(lines).toHaveLength(0)
530
+ })
531
+ })
532
+ })
533
+
534
+ describe('boot wiring: the sanitiser is the INNERMOST transformer', () => {
535
+ const src = readFileSync(
536
+ resolve(dirname(fileURLToPath(import.meta.url)), '..', 'gateway', 'gateway.ts'),
537
+ 'utf8',
538
+ )
539
+
540
+ it('installs it exactly once', () => {
541
+ const hits = src.match(/installUtf8Sanitizer\(bot\)/g) ?? []
542
+ expect(hits.length).toBe(1)
543
+ })
544
+
545
+ it('there is exactly ONE bot instance for that single install to cover', () => {
546
+ // The ordering assertion below is textual. A second `new Bot(` in
547
+ // gateway.ts would get no sanitiser at all while every test here stayed
548
+ // green, so pin the instance count too.
549
+ expect((src.match(/new Bot\(/g) ?? []).length).toBe(1)
550
+ })
551
+
552
+ it('installs it BEFORE every other transformer, so it composes innermost', () => {
553
+ // grammy: `call = trans(prev, ...)` — last installed is outermost, first
554
+ // installed is innermost. Innermost is the only position that guarantees
555
+ // no later transformer can reintroduce a lone surrogate behind our back.
556
+ const sanitizer = src.indexOf('installUtf8Sanitizer(bot)')
557
+ expect(sanitizer).toBeGreaterThan(-1)
558
+ for (const later of [
559
+ 'installTgPostLogger(bot)',
560
+ 'installRichMarkdownGuard(bot)',
561
+ 'installSentTextCapture(bot)',
562
+ 'installEditFloodFuse(bot',
563
+ ]) {
564
+ expect(src.indexOf(later), later).toBeGreaterThan(sanitizer)
565
+ }
566
+ })
567
+
568
+ it('nothing at all is installed between `new Bot(` and the sanitiser', () => {
569
+ // The named-installer check above is one-sided: it pins four KNOWN
570
+ // installers as later, but someone adding `installFoo(bot)` between
571
+ // `new Bot(TOKEN)` and `installUtf8Sanitizer(bot)` composes INNER of the
572
+ // sanitiser, sends unsanitised, and leaves every test in this file green.
573
+ // So pin it from the other side: the sanitiser must be the FIRST thing
574
+ // wired onto the bot, full stop.
575
+ const botIdx = src.search(/new Bot\(/)
576
+ expect(botIdx).toBeGreaterThan(-1)
577
+
578
+ const WIRING = /install[A-Za-z0-9_]*\(\s*bot\b|\.api\.config\.use\(/g
579
+ WIRING.lastIndex = botIdx
580
+ const first = WIRING.exec(src)
581
+ expect(first, 'no bot wiring found after `new Bot(`').not.toBeNull()
582
+ expect(
583
+ first![0],
584
+ `first bot wiring after \`new Bot(\` was \`${first![0]}\` — the UTF-8 ` +
585
+ 'sanitiser must be installed first so it composes innermost',
586
+ ).toBe('installUtf8Sanitizer(bot')
587
+ })
588
+ })